From 8e00fec700015a0cddb536df4af95337fcfb6ced Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 30 Mar 2026 14:43:42 -0600 Subject: [PATCH] Add file-backed personality support and listing --- codex-rs/Cargo.lock | 1 + .../schema/json/ClientRequest.json | 78 +-- .../codex_app_server_protocol.schemas.json | 139 ++++- .../codex_app_server_protocol.v2.schemas.json | 139 ++++- .../json/v2/PersonalitiesListParams.json | 22 + .../json/v2/PersonalitiesListResponse.json | 67 +++ .../schema/json/v2/ThreadResumeParams.json | 18 +- .../schema/json/v2/ThreadStartParams.json | 18 +- .../schema/json/v2/TurnStartParams.json | 22 +- .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/Personality.ts | 2 +- .../typescript/v2/PersonalitiesListEntry.ts | 6 + .../typescript/v2/PersonalitiesListParams.ts | 6 + .../v2/PersonalitiesListResponse.ts | 6 + .../typescript/v2/PersonalityMetadata.ts | 6 + .../schema/typescript/v2/PersonalityScope.ts | 5 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 43 ++ codex-rs/app-server/README.md | 28 +- .../app-server/src/codex_message_processor.rs | 89 +++- .../app-server/tests/common/mcp_process.rs | 9 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/personalities_list.rs | 135 +++++ .../tests/suite/v2/thread_resume.rs | 2 +- .../app-server/tests/suite/v2/turn_start.rs | 6 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/config.schema.json | 18 +- codex-rs/core/src/codex.rs | 34 +- .../src/codex/rollout_reconstruction_tests.rs | 16 +- codex-rs/core/src/codex_tests.rs | 37 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 8 +- codex-rs/core/src/config/edit.rs | 4 +- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/context_manager/history.rs | 5 +- codex-rs/core/src/context_manager/updates.rs | 36 +- codex-rs/core/src/guardian/review.rs | 2 +- codex-rs/core/src/guardian/review_session.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/personalities.rs | 487 ++++++++++++++++++ codex-rs/core/src/personality_migration.rs | 2 +- .../core/src/personality_migration_tests.rs | 6 +- codex-rs/core/src/state/service.rs | 2 + codex-rs/core/tests/suite/model_switching.rs | 2 +- .../core/tests/suite/model_visible_layout.rs | 8 +- codex-rs/core/tests/suite/personality.rs | 24 +- .../core/tests/suite/personality_migration.rs | 8 +- codex-rs/docs/protocol_v1.md | 2 +- codex-rs/protocol/src/config_types.rs | 94 +++- codex-rs/protocol/src/openai_models.rs | 46 +- codex-rs/tui/src/app.rs | 68 ++- codex-rs/tui/src/app_event.rs | 12 + codex-rs/tui/src/chatwidget.rs | 80 ++- sdk/python/docs/api-reference.md | 2 + sdk/python/scripts/update_sdk_artifacts.py | 24 +- sdk/python/src/codex_app_server/__init__.py | 2 +- sdk/python/src/codex_app_server/api.py | 36 +- .../src/codex_app_server/generated/v2_all.py | 154 +++++- .../src/codex_app_server/personality.py | 18 + 61 files changed, 1741 insertions(+), 366 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListEntry.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PersonalityMetadata.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PersonalityScope.ts create mode 100644 codex-rs/app-server/tests/suite/v2/personalities_list.rs create mode 100644 codex-rs/core/src/personalities.rs create mode 100644 sdk/python/src/codex_app_server/personality.py diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 967b020d18..f1fc24e7c6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1898,6 +1898,7 @@ dependencies = [ "seccompiler", "serde", "serde_json", + "serde_yaml", "serial_test", "sha1", "shlex", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..f08a8e7ffc 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1304,13 +1304,19 @@ ], "type": "string" }, - "Personality": { - "enum": [ - "none", - "friendly", - "pragmatic" - ], - "type": "string" + "PersonalitiesListParams": { + "properties": { + "cwds": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" }, "PluginInstallParams": { "properties": { @@ -2878,13 +2884,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { @@ -3061,13 +3063,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { @@ -3202,15 +3200,11 @@ "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } - ], - "description": "Override the personality for this turn and subsequent turns." + "description": "Override the personality for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "sandboxPolicy": { "anyOf": [ @@ -3821,6 +3815,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "personalities/list" + ], + "title": "Personalities/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PersonalitiesListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Personalities/listRequest", + "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 084da013e4..e0274c2d66 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 @@ -602,6 +602,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "personalities/list" + ], + "title": "Personalities/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PersonalitiesListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Personalities/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -9462,11 +9486,84 @@ } ] }, - "Personality": { + "PersonalitiesListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "personalities": { + "items": { + "$ref": "#/definitions/v2/PersonalityMetadata" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "personalities" + ], + "type": "object" + }, + "PersonalitiesListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PersonalitiesListParams", + "type": "object" + }, + "PersonalitiesListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/PersonalitiesListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PersonalitiesListResponse", + "type": "object" + }, + "PersonalityMetadata": { + "properties": { + "description": { + "type": "string" + }, + "isBuiltIn": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/v2/PersonalityScope" + } + }, + "required": [ + "description", + "isBuiltIn", + "name", + "scope" + ], + "type": "object" + }, + "PersonalityScope": { "enum": [ - "none", - "friendly", - "pragmatic" + "builtin", + "user", + "repo" ], "type": "string" }, @@ -13438,13 +13535,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { @@ -13719,13 +13812,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { @@ -14373,15 +14462,11 @@ "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Personality" - }, - { - "type": "null" - } - ], - "description": "Override the personality for this turn and subsequent turns." + "description": "Override the personality for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "sandboxPolicy": { "anyOf": [ 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 59dab32b19..19422f517d 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 @@ -1177,6 +1177,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "personalities/list" + ], + "title": "Personalities/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PersonalitiesListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Personalities/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -6276,11 +6300,84 @@ } ] }, - "Personality": { + "PersonalitiesListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "personalities": { + "items": { + "$ref": "#/definitions/PersonalityMetadata" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "personalities" + ], + "type": "object" + }, + "PersonalitiesListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PersonalitiesListParams", + "type": "object" + }, + "PersonalitiesListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PersonalitiesListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PersonalitiesListResponse", + "type": "object" + }, + "PersonalityMetadata": { + "properties": { + "description": { + "type": "string" + }, + "isBuiltIn": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/PersonalityScope" + } + }, + "required": [ + "description", + "isBuiltIn", + "name", + "scope" + ], + "type": "object" + }, + "PersonalityScope": { "enum": [ - "none", - "friendly", - "pragmatic" + "builtin", + "user", + "repo" ], "type": "string" }, @@ -11284,13 +11381,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { @@ -11565,13 +11658,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { @@ -12219,15 +12308,11 @@ "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } - ], - "description": "Override the personality for this turn and subsequent turns." + "description": "Override the personality for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "sandboxPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListParams.json new file mode 100644 index 0000000000..b110b1a02f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "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" + } + }, + "properties": { + "cwds": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PersonalitiesListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListResponse.json new file mode 100644 index 0000000000..aa08474416 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PersonalitiesListResponse.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PersonalitiesListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "personalities": { + "items": { + "$ref": "#/definitions/PersonalityMetadata" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "personalities" + ], + "type": "object" + }, + "PersonalityMetadata": { + "properties": { + "description": { + "type": "string" + }, + "isBuiltIn": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/PersonalityScope" + } + }, + "required": [ + "description", + "isBuiltIn", + "name", + "scope" + ], + "type": "object" + }, + "PersonalityScope": { + "enum": [ + "builtin", + "user", + "repo" + ], + "type": "string" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PersonalitiesListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PersonalitiesListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 3c8eb552ae..b31e4f8c02 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -315,14 +315,6 @@ } ] }, - "Personality": { - "enum": [ - "none", - "friendly", - "pragmatic" - ], - "type": "string" - }, "ReasoningItemContent": { "oneOf": [ { @@ -1043,13 +1035,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index b4391c7ab5..8f21a50248 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -79,14 +79,6 @@ ], "type": "object" }, - "Personality": { - "enum": [ - "none", - "friendly", - "pragmatic" - ], - "type": "string" - }, "SandboxMode": { "enum": [ "read-only", @@ -169,13 +161,9 @@ ] }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sandbox": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..17ad6f3531 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -113,14 +113,6 @@ ], "type": "string" }, - "Personality": { - "enum": [ - "none", - "friendly", - "pragmatic" - ], - "type": "string" - }, "ReadOnlyAccess": { "oneOf": [ { @@ -556,15 +548,11 @@ "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, "personality": { - "anyOf": [ - { - "$ref": "#/definitions/Personality" - }, - { - "type": "null" - } - ], - "description": "Override the personality for this turn and subsequent turns." + "description": "Override the personality for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "sandboxPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a986359..614857f710 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -35,6 +35,7 @@ import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { ModelListParams } from "./v2/ModelListParams"; +import type { PersonalitiesListParams } from "./v2/PersonalitiesListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; @@ -64,4 +65,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": "skills/list", id: RequestId, params: SkillsListParams, } | { "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": "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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "personalities/list", id: RequestId, params: PersonalitiesListParams, } | { "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": "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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/Personality.ts b/codex-rs/app-server-protocol/schema/typescript/Personality.ts index 45165f4e33..d82dc572bc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/Personality.ts +++ b/codex-rs/app-server-protocol/schema/typescript/Personality.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Personality = "none" | "friendly" | "pragmatic"; +export type Personality = string; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListEntry.ts new file mode 100644 index 0000000000..ab7cf38534 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListEntry.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 { PersonalityMetadata } from "./PersonalityMetadata"; + +export type PersonalitiesListEntry = { cwd: string, personalities: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListParams.ts new file mode 100644 index 0000000000..f4322af6e3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListParams.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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PersonalitiesListParams = { cwds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListResponse.ts new file mode 100644 index 0000000000..25fdfe9e24 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalitiesListResponse.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 { PersonalitiesListEntry } from "./PersonalitiesListEntry"; + +export type PersonalitiesListResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PersonalityMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalityMetadata.ts new file mode 100644 index 0000000000..4e4879bfad --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalityMetadata.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 { PersonalityScope } from "./PersonalityScope"; + +export type PersonalityMetadata = { name: string, description: string, scope: PersonalityScope, isBuiltIn: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PersonalityScope.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalityScope.ts new file mode 100644 index 0000000000..16932a26f7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PersonalityScope.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 PersonalityScope = "builtin" | "user" | "repo"; 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 d0687e5f1d..d1f4fa2a52 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -209,6 +209,11 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; +export type { PersonalitiesListEntry } from "./PersonalitiesListEntry"; +export type { PersonalitiesListParams } from "./PersonalitiesListParams"; +export type { PersonalitiesListResponse } from "./PersonalitiesListResponse"; +export type { PersonalityMetadata } from "./PersonalityMetadata"; +export type { PersonalityScope } from "./PersonalityScope"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginAuthPolicy } from "./PluginAuthPolicy"; export type { PluginDetail } from "./PluginDetail"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 30061c716e..5bb74d9831 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -321,6 +321,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + PersonalitiesList => "personalities/list" { + params: v2::PersonalitiesListParams, + response: v2::PersonalitiesListResponse, + }, PluginList => "plugin/list" { params: v2::PluginListParams, response: v2::PluginListResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9373c852d8..e498261c2d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3182,6 +3182,21 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PersonalitiesListParams { + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PersonalitiesListResponse { + pub data: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3327,6 +3342,34 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum PersonalityScope { + Builtin, + User, + Repo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PersonalityMetadata { + pub name: String, + pub description: String, + pub scope: PersonalityScope, + pub is_built_in: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PersonalitiesListEntry { + pub cwd: PathBuf, + pub personalities: Vec, +} + #[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 4365cb8e29..89c1ebc746 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -176,6 +176,7 @@ Example with notification opt-out: - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `personalities/list` — list built-in and file-backed personalities for one or more `cwd` values. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. @@ -237,7 +238,7 @@ Start a fresh thread when you need a new Codex conversation. { "method": "thread/started", "params": { "thread": { … } } } ``` -Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string. +Built-in `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. Clients may also pass custom personality ids discovered via `personalities/list`. Custom personalities are loaded from Markdown files in `.codex/personalities` for the active repo and `$CODEX_HOME/personalities` for the user; invalid files are logged and ignored. When `"none"` is selected, no personality instructions are injected. To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. @@ -1185,6 +1186,31 @@ The server also emits `skills/changed` notifications when watched local skill fi } ``` +Use `personalities/list` to fetch the available built-in and file-backed personalities for one or more working directories. + +```json +{ "method": "personalities/list", "id": 26, "params": { + "cwds": ["/Users/me/project"] +} } +{ "id": 26, "result": { "data": [{ + "cwd": "/Users/me/project", + "personalities": [ + { + "name": "friendly", + "description": "Warm, collaborative, and helpful.", + "scope": "builtin", + "isBuiltIn": true + }, + { + "name": "night-owl", + "description": "Late-night, direct, and fast.", + "scope": "repo", + "isBuiltIn": false + } + ] +}] } } +``` + To enable or disable a skill by absolute path: ```json diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c2854e663b..1bc9436360 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -83,6 +83,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PersonalitiesListParams; +use codex_app_server_protocol::PersonalitiesListResponse; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -779,6 +781,10 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } + ClientRequest::PersonalitiesList { request_id, params } => { + self.personalities_list(to_connection_request_id(request_id), params) + .await; + } ClientRequest::PluginList { request_id, params } => { self.plugin_list(to_connection_request_id(request_id), params) .await; @@ -5787,6 +5793,61 @@ impl CodexMessageProcessor { .await; } + async fn personalities_list( + &self, + request_id: ConnectionRequestId, + params: PersonalitiesListParams, + ) { + let cwds = params + .cwds + .map(|cwds| { + cwds.into_iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) + .collect::>() + }) + .filter(|cwds| !cwds.is_empty()) + .unwrap_or_else(|| vec![self.config.cwd.to_path_buf()]); + + let cli_overrides = self.current_cli_overrides(); + let mut data = Vec::new(); + for cwd in cwds { + let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) { + Ok(path) => path, + Err(err) => { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + }; + let config_layer_stack = match load_config_layers_state( + &self.config.codex_home, + Some(cwd_abs), + &cli_overrides, + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await + { + Ok(config_layer_stack) => config_layer_stack, + Err(err) => { + self.outgoing + .send_error(request_id, config_load_error(&err)) + .await; + return; + } + }; + let catalog = codex_core::personalities::catalog_from_layer_stack(&config_layer_stack); + data.push(codex_app_server_protocol::PersonalitiesListEntry { + cwd, + personalities: personalities_to_info(catalog.personalities()), + }); + } + + self.outgoing + .send_response(request_id, PersonalitiesListResponse { data }) + .await; + } + async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { let plugins_manager = self.thread_manager.plugins_manager(); let PluginListParams { @@ -6390,7 +6451,7 @@ impl CodexMessageProcessor { summary: params.summary, service_tier: params.service_tier, collaboration_mode, - personality: params.personality, + personality: params.personality.clone(), }, ) .await; @@ -7986,6 +8047,32 @@ fn skills_to_info( .collect() } +fn personalities_to_info( + personalities: &[codex_core::personalities::PersonalityDefinition], +) -> Vec { + personalities + .iter() + .map( + |personality| codex_app_server_protocol::PersonalityMetadata { + name: personality.name.to_string(), + description: personality.description.clone(), + scope: match personality.scope { + codex_core::personalities::PersonalityScope::Builtin => { + codex_app_server_protocol::PersonalityScope::Builtin + } + codex_core::personalities::PersonalityScope::User => { + codex_app_server_protocol::PersonalityScope::User + } + codex_core::personalities::PersonalityScope::Repo => { + codex_app_server_protocol::PersonalityScope::Repo + } + }, + is_built_in: personality.is_builtin, + }, + ) + .collect() +} + fn plugin_skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_skill_paths: &std::collections::HashSet, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0345028d88..73529860e2 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -490,6 +490,15 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `personalities/list` JSON-RPC request. + pub async fn send_personalities_list_request( + &mut self, + params: codex_app_server_protocol::PersonalitiesListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("personalities/list", params).await + } + /// Send a `plugin/install` JSON-RPC request. pub async fn send_plugin_install_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index b4e24ebe28..b3408954e6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -17,6 +17,7 @@ mod initialize; mod mcp_server_elicitation; mod model_list; mod output_schema; +mod personalities_list; mod plan_item; mod plugin_install; mod plugin_list; diff --git a/codex-rs/app-server/tests/suite/v2/personalities_list.rs b/codex-rs/app-server/tests/suite/v2/personalities_list.rs new file mode 100644 index 0000000000..cf0d475de4 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/personalities_list.rs @@ -0,0 +1,135 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PersonalitiesListParams; +use codex_app_server_protocol::PersonalitiesListResponse; +use codex_app_server_protocol::PersonalityScope; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +fn write_personality( + root: &std::path::Path, + name: &str, + description: &str, + body: &str, +) -> Result<()> { + std::fs::create_dir_all(root)?; + std::fs::write( + root.join(format!("{name}.md")), + format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"), + )?; + Ok(()) +} + +#[tokio::test] +async fn personalities_list_returns_builtin_and_file_backed_personalities() -> Result<()> { + let codex_home = TempDir::new()?; + let repo = TempDir::new()?; + let repo_personalities = repo.path().join(".codex/personalities"); + let user_personalities = codex_home.path().join("personalities"); + + write_personality( + &user_personalities, + "night-owl", + "User personality", + "User instructions", + )?; + write_personality( + &repo_personalities, + "night-owl", + "Repo personality", + "Repo instructions", + )?; + write_personality( + &repo_personalities, + "ship-it", + "Repo only", + "Ship it instructions", + )?; + std::fs::write( + repo_personalities.join("broken.md"), + "# missing frontmatter\n", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_personalities_list_request(PersonalitiesListParams { + cwds: Some(vec![repo.path().to_path_buf().try_into()?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let PersonalitiesListResponse { data } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].cwd, repo.path().to_path_buf()); + + let personalities = &data[0].personalities; + let names = personalities + .iter() + .map(|personality| personality.name.as_str()) + .collect::>(); + assert_eq!( + names, + vec!["friendly", "none", "pragmatic", "night-owl", "ship-it"] + ); + assert!(personalities.iter().any(|personality| { + personality.name == "friendly" + && personality.is_built_in + && personality.scope == PersonalityScope::Builtin + })); + assert!(personalities.iter().any(|personality| { + personality.name == "pragmatic" + && personality.is_built_in + && personality.scope == PersonalityScope::Builtin + })); + assert!(personalities.iter().any(|personality| { + personality.name == "none" + && personality.is_built_in + && personality.scope == PersonalityScope::Builtin + })); + + let night_owl = personalities + .iter() + .find(|personality| personality.name == "night-owl") + .ok_or_else(|| anyhow!("night-owl personality missing"))?; + assert_eq!(night_owl.description, "Repo personality"); + assert_eq!(night_owl.scope, PersonalityScope::Repo); + assert!(!night_owl.is_built_in); + + let ship_it = personalities + .iter() + .find(|personality| personality.name == "ship-it") + .ok_or_else(|| anyhow!("ship-it personality missing"))?; + assert_eq!(ship_it.description, "Repo only"); + assert_eq!(ship_it.scope, PersonalityScope::Repo); + assert!(!ship_it.is_built_in); + + assert!( + personalities + .iter() + .all(|personality| personality.name != "broken") + ); + assert_eq!( + personalities + .iter() + .filter(|personality| personality.name == "night-owl") + .count(), + 1 + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 3a93451326..0c2c836706 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1809,7 +1809,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id, model: Some("gpt-5.2-codex".to_string()), - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3155a4be9c..9078a595ac 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -719,7 +719,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { text: "Hello".to_string(), text_elements: Vec::new(), }], - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), ..Default::default() }) .await?; @@ -824,7 +824,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { text: "Hello again".to_string(), text_elements: Vec::new(), }], - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), ..Default::default() }) .await?; @@ -897,7 +897,7 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> let persisted_toml: ConfigToml = toml::from_str(&std::fs::read_to_string( codex_home.path().join("config.toml"), )?)?; - assert_eq!(persisted_toml.personality, Some(Personality::Pragmatic)); + assert_eq!(persisted_toml.personality, Some(Personality::pragmatic())); assert!( codex_home .path() diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e40066027d..92dd81e1cb 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -93,6 +93,7 @@ rmcp = { workspace = true, default-features = false, features = [ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_yaml = { workspace = true } sha1 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5876d463ce..ffbde4abae 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -567,7 +567,7 @@ "type": "string" }, "personality": { - "$ref": "#/definitions/Personality" + "type": "string" }, "plan_mode_reasoning_effort": { "$ref": "#/definitions/ReasoningEffort" @@ -1290,14 +1290,6 @@ "PermissionsToml": { "type": "object" }, - "Personality": { - "enum": [ - "none", - "friendly", - "pragmatic" - ], - "type": "string" - }, "PluginConfig": { "additionalProperties": false, "properties": { @@ -2431,12 +2423,8 @@ "description": "Named permissions profiles." }, "personality": { - "allOf": [ - { - "$ref": "#/definitions/Personality" - } - ], - "description": "Optionally specify a personality for the model" + "description": "Optionally specify a personality for the model", + "type": "string" }, "plan_mode_reasoning_effort": { "$ref": "#/definitions/ReasoningEffort" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7137bf4d3a..dd6b94aa3d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; use crate::path_utils::normalize_for_native_workdir; +use crate::personalities::catalog_for_config; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -572,7 +573,7 @@ impl Codex { .base_instructions .clone() .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)); + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())); // Respect thread-start tools. When missing (resumed/forked threads), read from the db // first, then fall back to rollout-file tools. @@ -618,7 +619,7 @@ impl Codex { service_tier: config.service_tier, developer_instructions: config.developer_instructions.clone(), user_instructions, - personality: config.personality, + personality: config.personality.clone(), base_instructions, compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), @@ -970,7 +971,7 @@ impl TurnContext { compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, - personality: self.personality, + personality: self.personality.clone(), approval_policy: self.approval_policy.clone(), sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), @@ -1017,7 +1018,7 @@ impl TurnContext { sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), model: self.model_info.slug.clone(), - personality: self.personality, + personality: self.personality.clone(), collaboration_mode: Some(self.collaboration_mode.clone()), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort, @@ -1133,7 +1134,7 @@ impl SessionConfiguration { cwd: self.cwd.to_path_buf(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), - personality: self.personality, + personality: self.personality.clone(), session_source: self.session_source.clone(), } } @@ -1154,7 +1155,7 @@ impl SessionConfiguration { if let Some(service_tier) = updates.service_tier { next_configuration.service_tier = service_tier; } - if let Some(personality) = updates.personality { + if let Some(personality) = updates.personality.clone() { next_configuration.personality = Some(personality); } if let Some(approval_policy) = updates.approval_policy { @@ -1297,7 +1298,7 @@ impl Session { session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.service_tier = session_configuration.service_tier; - per_turn_config.personality = session_configuration.personality; + per_turn_config.personality = session_configuration.personality.clone(); per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; let resolved_web_search_mode = resolve_web_search_mode_for_turn( &per_turn_config.web_search_mode, @@ -1436,7 +1437,7 @@ impl Session { compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), collaboration_mode: session_configuration.collaboration_mode.clone(), - personality: session_configuration.personality, + personality: session_configuration.personality.clone(), approval_policy: session_configuration.approval_policy.clone(), sandbox_policy: session_configuration.sandbox_policy.clone(), file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(), @@ -1884,6 +1885,7 @@ impl Session { auth_manager: Arc::clone(&auth_manager), session_telemetry, models_manager: Arc::clone(&models_manager), + personality_catalog: Arc::new(catalog_for_config(config.as_ref())), tool_approvals: Mutex::new(ApprovalStore::default()), skills_manager, plugins_manager: Arc::clone(&plugins_manager), @@ -2628,6 +2630,7 @@ impl Session { current_context, shell.as_ref(), exec_policy.as_ref(), + self.services.personality_catalog.as_ref(), self.features.enabled(Feature::Personality), ) } @@ -3613,16 +3616,19 @@ impl Session { developer_sections.push(realtime_update.into_text()); } if self.features.enabled(Feature::Personality) - && let Some(personality) = turn_context.personality + && let Some(personality) = turn_context.personality.clone() { let model_info = turn_context.model_info.clone(); - let has_baked_personality = model_info.supports_personality() - && base_instructions == model_info.get_model_instructions(Some(personality)); + let has_baked_personality = personality.is_builtin() + && model_info.supports_personality() + && base_instructions + == model_info.get_model_instructions(Some(personality.clone())); if !has_baked_personality && let Some(personality_message) = crate::context_manager::updates::personality_message_for( &model_info, - personality, + &self.services.personality_catalog, + &personality, ) { developer_sections.push( @@ -5541,7 +5547,7 @@ async fn spawn_review_thread( user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), - personality: parent_turn_context.personality, + personality: parent_turn_context.personality.clone(), approval_policy: parent_turn_context.approval_policy.clone(), sandbox_policy: parent_turn_context.sandbox_policy.clone(), file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(), @@ -6418,7 +6424,7 @@ pub(crate) fn build_prompt( tools, parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, + personality: turn_context.personality.clone(), output_schema: turn_context.final_output_json_schema.clone(), } } diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 86abfa6756..f67c10731f 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -69,7 +69,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -108,7 +108,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -858,7 +858,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -930,7 +930,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -959,7 +959,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -1065,7 +1065,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: current_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -1167,7 +1167,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -1311,7 +1311,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f5e5d4463e..246cffba92 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1268,7 +1268,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), - personality: turn_context.personality, + personality: turn_context.personality.clone(), collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, @@ -1801,11 +1801,11 @@ async fn set_rate_limits_retains_previous_credits() { developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, - personality: config.personality, + personality: config.personality.clone(), base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -1899,11 +1899,11 @@ async fn set_rate_limits_updates_plan_type_when_present() { developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, - personality: config.personality, + personality: config.personality.clone(), base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2243,11 +2243,11 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, - personality: config.personality, + personality: config.personality.clone(), base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2505,11 +2505,11 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, - personality: config.personality, + personality: config.personality.clone(), base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2602,11 +2602,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, - personality: config.personality, + personality: config.personality.clone(), base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2682,6 +2682,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { auth_manager: auth_manager.clone(), session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), + personality_catalog: Arc::new(crate::personalities::catalog_for_config(config.as_ref())), tool_approvals: Mutex::new(ApprovalStore::default()), skills_manager, plugins_manager, @@ -3104,7 +3105,7 @@ async fn user_turn_updates_approvals_reviewer() { service_tier: None, final_output_json_schema: None, collaboration_mode: None, - personality: config.personality, + personality: config.personality.clone(), }, ) .await; @@ -3445,11 +3446,11 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, - personality: config.personality, + personality: config.personality.clone(), base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -3525,6 +3526,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( auth_manager: Arc::clone(&auth_manager), session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), + personality_catalog: Arc::new(crate::personalities::catalog_for_config(config.as_ref())), tool_approvals: Mutex::new(ApprovalStore::default()), skills_manager, plugins_manager, @@ -4944,13 +4946,16 @@ async fn sample_rollout( && content.iter().any(|c| { matches!(c, ContentItem::InputText { text } if text.contains("")) })) - }) && let Some(p) = reconstruction_turn.personality + }) && let Some(p) = reconstruction_turn.personality.as_ref() && session.features.enabled(Feature::Personality) && let Some(personality_message) = reconstruction_turn .model_info .model_messages .as_ref() - .and_then(|m| m.get_personality_message(Some(p)).filter(|s| !s.is_empty())) + .and_then(|m| { + m.get_personality_message(Some(p.clone())) + .filter(|s| !s.is_empty()) + }) { let msg = DeveloperInstructions::personality_spec_message(personality_message).into(); let insert_at = initial_context diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 82fd5c15e3..1bc2500212 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -122,7 +122,7 @@ async fn run_compact_task_inner( let prompt = Prompt { input: turn_input, base_instructions: sess.get_base_instructions().await, - personality: turn_context.personality, + personality: turn_context.personality.clone(), ..Default::default() }; let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 28be574318..538f23e681 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -110,7 +110,7 @@ async fn run_remote_compact_task_inner_impl( tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, - personality: turn_context.personality, + personality: turn_context.personality.clone(), output_schema: None, }; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index da0936f27e..79b2037acf 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4457,7 +4457,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_supports_reasoning_summaries: None, model_catalog: None, model_verbosity: None, - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, @@ -4599,7 +4599,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_supports_reasoning_summaries: None, model_catalog: None, model_verbosity: None, - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, @@ -4739,7 +4739,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_supports_reasoning_summaries: None, model_catalog: None, model_verbosity: None, - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, @@ -4865,7 +4865,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_supports_reasoning_summaries: None, model_catalog: None, model_verbosity: Some(Verbosity::High), - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 370c46ce4c..3deae38183 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -383,7 +383,9 @@ impl ConfigDocument { )), ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( &["personality"], - personality.map(|personality| value(personality.to_string())), + personality + .clone() + .map(|personality| value(personality.to_string())), )), ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( Scope::Global, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 58cffc2659..53d11d8036 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2431,7 +2431,7 @@ impl Config { .or_else(|| { features .enabled(Feature::Personality) - .then_some(Personality::Pragmatic) + .then_some(Personality::pragmatic()) }); let experimental_compact_prompt_path = config_profile diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index ec2df30d3b..7b07b48a42 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -130,7 +130,10 @@ impl ContextManager { // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option { let model_info = &turn_context.model_info; - let personality = turn_context.personality.or(turn_context.config.personality); + let personality = turn_context + .personality + .clone() + .or(turn_context.config.personality.clone()); let base_instructions = BaseInstructions { text: model_info.get_model_instructions(personality), }; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 34fd41c083..fbdf0e83ca 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,6 +1,7 @@ use crate::codex::PreviousTurnSettings; use crate::codex::TurnContext; use crate::environment_context::EnvironmentContext; +use crate::personalities::PersonalityCatalog; use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; @@ -107,6 +108,7 @@ pub(crate) fn build_initial_realtime_item( fn build_personality_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, + personality_catalog: &PersonalityCatalog, personality_feature_enabled: bool, ) -> Option { if !personality_feature_enabled { @@ -117,11 +119,12 @@ fn build_personality_update_item( return None; } - if let Some(personality) = next.personality + if let Some(personality) = next.personality.clone() && next.personality != previous.personality { let model_info = &next.model_info; - let personality_message = personality_message_for(model_info, personality); + let personality_message = + personality_message_for(model_info, personality_catalog, &personality); personality_message.map(DeveloperInstructions::personality_spec_message) } else { None @@ -130,13 +133,22 @@ fn build_personality_update_item( pub(crate) fn personality_message_for( model_info: &ModelInfo, - personality: Personality, + personality_catalog: &PersonalityCatalog, + personality: &Personality, ) -> Option { - model_info + let builtin_message = model_info .model_messages .as_ref() - .and_then(|spec| spec.get_personality_message(Some(personality))) - .filter(|message| !message.is_empty()) + .and_then(|spec| spec.get_personality_message(Some(personality.clone()))) + .filter(|message| !message.is_empty()); + if builtin_message.is_some() { + builtin_message + } else { + personality_catalog + .instructions_for(personality) + .map(ToOwned::to_owned) + .filter(|message| !message.is_empty()) + } } pub(crate) fn build_model_instructions_update_item( @@ -148,7 +160,9 @@ pub(crate) fn build_model_instructions_update_item( return None; } - let model_instructions = next.model_info.get_model_instructions(next.personality); + let model_instructions = next + .model_info + .get_model_instructions(next.personality.clone()); if model_instructions.is_empty() { return None; } @@ -191,6 +205,7 @@ pub(crate) fn build_settings_update_items( next: &TurnContext, shell: &Shell, exec_policy: &Policy, + personality_catalog: &PersonalityCatalog, personality_feature_enabled: bool, ) -> Vec { // TODO(ccunningham): build_settings_update_items still does not cover every @@ -205,7 +220,12 @@ pub(crate) fn build_settings_update_items( build_permissions_update_item(previous, next, exec_policy), build_collaboration_mode_update_item(previous, next), build_realtime_update_item(previous, previous_turn_settings, next), - build_personality_update_item(previous, next, personality_feature_enabled), + build_personality_update_item( + previous, + next, + personality_catalog, + personality_feature_enabled, + ), ] .into_iter() .flatten() diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 3a491f6efb..558420080c 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -331,7 +331,7 @@ pub(super) async fn run_guardian_review_session( model: guardian_model, reasoning_effort: guardian_reasoning_effort, reasoning_summary: turn.reasoning_summary, - personality: turn.personality, + personality: turn.personality.clone(), external_cancel, }) .await diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 17993a9743..5dd7eb0aed 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -522,7 +522,7 @@ async fn run_review_on_session( service_tier: None, final_output_json_schema: Some(params.schema.clone()), collaboration_mode: None, - personality: params.personality, + personality: params.personality.clone(), }) .await }), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7e28af14a0..5970b65595 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -63,6 +63,7 @@ pub mod message_history; mod model_provider_info; pub mod utils; pub use utils::path_utils; +pub mod personalities; pub mod personality_migration; pub mod plugins; mod provider_auth; diff --git a/codex-rs/core/src/personalities.rs b/codex-rs/core/src/personalities.rs new file mode 100644 index 0000000000..64a527efc6 --- /dev/null +++ b/codex-rs/core/src/personalities.rs @@ -0,0 +1,487 @@ +/*! +Loads built-in and file-backed personality definitions, resolves precedence +between repo and user scopes, and exposes the catalog used by config, prompt +construction, and the app-server personality picker. +*/ + +use crate::config::Config; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_protocol::config_types::Personality; +use serde::Deserialize; +use std::collections::HashMap; +use std::collections::HashSet; +use std::error::Error; +use std::fmt; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; + +const PERSONALITIES_DIR_NAME: &str = "personalities"; +const MAX_NAME_LEN: usize = 64; +const MAX_DESCRIPTION_LEN: usize = 1024; +const BUILTIN_FRIENDLY_INSTRUCTIONS: &str = + include_str!("../templates/personalities/gpt-5.2-codex_friendly.md"); +const BUILTIN_PRAGMATIC_INSTRUCTIONS: &str = + include_str!("../templates/personalities/gpt-5.2-codex_pragmatic.md"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonalityScope { + Builtin, + User, + Repo, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonalityDefinition { + pub name: Personality, + pub description: String, + pub instructions: Option, + pub scope: PersonalityScope, + pub is_builtin: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct PersonalityCatalog { + personalities: Vec, + by_name: HashMap, +} + +impl PersonalityCatalog { + pub fn personalities(&self) -> &[PersonalityDefinition] { + self.personalities.as_slice() + } + + pub fn get(&self, personality: &Personality) -> Option<&PersonalityDefinition> { + self.by_name.get(personality) + } + + pub fn instructions_for(&self, personality: &Personality) -> Option<&str> { + self.get(personality) + .and_then(|definition| definition.instructions.as_deref()) + } +} + +#[derive(Debug, Default)] +struct PersonalityLoadOutcome { + personalities: Vec, + errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PersonalityError { + path: PathBuf, + message: String, +} + +#[derive(Debug, Deserialize)] +struct PersonalityFrontmatter { + name: String, + description: String, +} + +#[derive(Debug)] +enum PersonalityParseError { + Read(std::io::Error), + MissingFrontmatter, + InvalidYaml(serde_yaml::Error), + MissingBody, + InvalidName(String), + InvalidField { field: &'static str, reason: String }, +} + +impl fmt::Display for PersonalityParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PersonalityParseError::Read(error) => write!(f, "failed to read file: {error}"), + PersonalityParseError::MissingFrontmatter => { + write!(f, "missing YAML frontmatter delimited by ---") + } + PersonalityParseError::InvalidYaml(error) => write!(f, "invalid YAML: {error}"), + PersonalityParseError::MissingBody => write!(f, "missing markdown body"), + PersonalityParseError::InvalidName(reason) => write!(f, "invalid name: {reason}"), + PersonalityParseError::InvalidField { field, reason } => { + write!(f, "invalid {field}: {reason}") + } + } + } +} + +impl Error for PersonalityParseError {} + +pub fn catalog_for_config(config: &Config) -> PersonalityCatalog { + catalog_from_layer_stack(&config.config_layer_stack) +} + +pub fn catalog_from_layer_stack(config_layer_stack: &ConfigLayerStack) -> PersonalityCatalog { + let mut outcome = load_personalities_from_layer_stack(config_layer_stack); + for error in &outcome.errors { + warn!( + path = %error.path.display(), + error = %error.message, + "ignoring invalid personality definition" + ); + } + + outcome.personalities.sort_by(|left, right| { + left.is_builtin + .cmp(&right.is_builtin) + .reverse() + .then_with(|| { + left.name + .as_str() + .to_ascii_lowercase() + .cmp(&right.name.as_str().to_ascii_lowercase()) + }) + .then_with(|| left.name.as_str().cmp(right.name.as_str())) + }); + + let mut by_name = HashMap::new(); + for personality in outcome.personalities.iter().cloned() { + by_name.insert(personality.name.clone(), personality); + } + PersonalityCatalog { + personalities: outcome.personalities, + by_name, + } +} + +fn load_personalities_from_layer_stack( + config_layer_stack: &ConfigLayerStack, +) -> PersonalityLoadOutcome { + let mut outcome = PersonalityLoadOutcome { + personalities: builtin_personalities(), + ..Default::default() + }; + let mut seen: HashSet = outcome + .personalities + .iter() + .map(|personality| personality.name.clone()) + .collect(); + + for (root, scope) in personality_roots(config_layer_stack) { + discover_personalities_under_root(root.as_path(), scope, &mut seen, &mut outcome); + } + + outcome +} + +fn builtin_personalities() -> Vec { + vec![ + PersonalityDefinition { + name: Personality::none(), + description: "No personality instructions.".to_string(), + instructions: Some(String::new()), + scope: PersonalityScope::Builtin, + is_builtin: true, + }, + PersonalityDefinition { + name: Personality::friendly(), + description: "Warm, collaborative, and helpful.".to_string(), + instructions: Some(BUILTIN_FRIENDLY_INSTRUCTIONS.to_string()), + scope: PersonalityScope::Builtin, + is_builtin: true, + }, + PersonalityDefinition { + name: Personality::pragmatic(), + description: "Concise, task-focused, and direct.".to_string(), + instructions: Some(BUILTIN_PRAGMATIC_INSTRUCTIONS.to_string()), + scope: PersonalityScope::Builtin, + is_builtin: true, + }, + ] +} + +fn personality_roots(config_layer_stack: &ConfigLayerStack) -> Vec<(PathBuf, PersonalityScope)> { + let mut roots = Vec::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ true, + ) { + let Some(config_folder) = layer.config_folder() else { + continue; + }; + + let scope = match &layer.name { + ConfigLayerSource::Project { .. } => PersonalityScope::Repo, + ConfigLayerSource::User { .. } => PersonalityScope::User, + _ => continue, + }; + roots.push((config_folder.as_path().join(PERSONALITIES_DIR_NAME), scope)); + } + + let mut seen = HashSet::new(); + roots.retain(|(path, _)| seen.insert(path.clone())); + roots +} + +fn discover_personalities_under_root( + root: &Path, + scope: PersonalityScope, + seen: &mut HashSet, + outcome: &mut PersonalityLoadOutcome, +) { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => { + outcome.errors.push(PersonalityError { + path: root.to_path_buf(), + message: error.to_string(), + }); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() + || !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + { + continue; + } + + match parse_personality_file(path.as_path(), scope) { + Ok(personality) => { + if seen.insert(personality.name.clone()) { + outcome.personalities.push(personality); + } + } + Err(error) => { + outcome.errors.push(PersonalityError { + path, + message: error.to_string(), + }); + } + } + } +} + +fn parse_personality_file( + path: &Path, + scope: PersonalityScope, +) -> Result { + let contents = fs::read_to_string(path).map_err(PersonalityParseError::Read)?; + let (frontmatter, body) = + extract_frontmatter_and_body(&contents).ok_or(PersonalityParseError::MissingFrontmatter)?; + let parsed: PersonalityFrontmatter = + serde_yaml::from_str(frontmatter.as_str()).map_err(PersonalityParseError::InvalidYaml)?; + + let name = sanitize_single_line(parsed.name.as_str()); + validate_name(name.as_str())?; + + let description = sanitize_single_line(parsed.description.as_str()); + validate_len(description.as_str(), MAX_DESCRIPTION_LEN, "description")?; + + let instructions = body.trim(); + if instructions.is_empty() { + return Err(PersonalityParseError::MissingBody); + } + + Ok(PersonalityDefinition { + name: Personality::from(name), + description, + instructions: Some(instructions.to_string()), + scope, + is_builtin: false, + }) +} + +fn validate_name(name: &str) -> Result<(), PersonalityParseError> { + validate_len(name, MAX_NAME_LEN, "name")?; + let normalized = name.to_ascii_lowercase(); + if Personality::builtin_names().contains(&normalized.as_str()) { + return Err(PersonalityParseError::InvalidName(format!( + "`{name}` is reserved for a built-in personality" + ))); + } + Ok(()) +} + +fn validate_len( + value: &str, + max_len: usize, + field_name: &'static str, +) -> Result<(), PersonalityParseError> { + if value.is_empty() { + return Err(PersonalityParseError::InvalidField { + field: field_name, + reason: "value is empty".to_string(), + }); + } + if value.chars().count() > max_len { + return Err(PersonalityParseError::InvalidField { + field: field_name, + reason: format!("exceeds maximum length of {max_len} characters"), + }); + } + Ok(()) +} + +fn sanitize_single_line(raw: &str) -> String { + raw.split_whitespace().collect::>().join(" ") +} + +fn extract_frontmatter_and_body(contents: &str) -> Option<(String, String)> { + let mut lines = contents.lines(); + if !matches!(lines.next(), Some(line) if line.trim() == "---") { + return None; + } + + let mut frontmatter_lines = Vec::new(); + let mut found_closing = false; + for line in lines.by_ref() { + if line.trim() == "---" { + found_closing = true; + break; + } + frontmatter_lines.push(line); + } + + if frontmatter_lines.is_empty() || !found_closing { + return None; + } + + let body = lines.collect::>().join("\n"); + Some((frontmatter_lines.join("\n"), body)) +} + +#[cfg(test)] +mod tests { + use super::catalog_from_layer_stack; + use crate::config_loader::CloudRequirementsLoader; + use crate::config_loader::LoaderOverrides; + use crate::config_loader::load_config_layers_state; + use codex_protocol::config_types::Personality; + use pretty_assertions::assert_eq; + use std::fs; + use tempfile::TempDir; + + fn write_personality(root: &std::path::Path, name: &str, description: &str, body: &str) { + fs::create_dir_all(root).unwrap(); + let path = root.join(format!("{name}.md")); + fs::write( + path, + format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"), + ) + .unwrap(); + } + + #[tokio::test] + async fn repo_personality_overrides_user_personality_name() { + let temp = TempDir::new().unwrap(); + let codex_home = temp.path().join(".codex-home"); + let repo_root = temp.path().join("repo"); + fs::create_dir_all(repo_root.join(".codex")).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + + write_personality( + codex_home.join("personalities").as_path(), + "night-owl", + "User personality", + "user body", + ); + write_personality( + repo_root.join(".codex/personalities").as_path(), + "night-owl", + "Repo personality", + "repo body", + ); + + let stack = load_config_layers_state( + &codex_home, + Some(repo_root.clone().try_into().unwrap()), + &[], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await + .unwrap(); + + let catalog = catalog_from_layer_stack(&stack); + let personality = catalog.get(&Personality::from("night-owl")).unwrap(); + assert_eq!(personality.description, "Repo personality"); + assert_eq!(personality.instructions.as_deref(), Some("repo body")); + } + + #[tokio::test] + async fn invalid_personality_is_ignored() { + let temp = TempDir::new().unwrap(); + let codex_home = temp.path().join(".codex-home"); + let repo_root = temp.path().join("repo"); + fs::create_dir_all(repo_root.join(".codex/personalities")).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + fs::write( + repo_root.join(".codex/personalities/bad.md"), + "---\nname: friendly\ndescription: bad\n---\n\nbody\n", + ) + .unwrap(); + + let stack = load_config_layers_state( + &codex_home, + Some(repo_root.clone().try_into().unwrap()), + &[], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await + .unwrap(); + + let catalog = catalog_from_layer_stack(&stack); + assert!( + catalog + .get(&Personality::from("friendly")) + .unwrap() + .is_builtin + ); + assert!(catalog.get(&Personality::from("bad")).is_none()); + } + + #[tokio::test] + async fn personalities_are_sorted_with_builtins_first_then_name() { + let temp = TempDir::new().unwrap(); + let codex_home = temp.path().join(".codex-home"); + let repo_root = temp.path().join("repo"); + fs::create_dir_all(repo_root.join(".codex")).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + + write_personality( + codex_home.join("personalities").as_path(), + "zebra", + "User personality", + "zebra body", + ); + write_personality( + repo_root.join(".codex/personalities").as_path(), + "alpha", + "Repo personality", + "alpha body", + ); + + let stack = load_config_layers_state( + &codex_home, + Some(repo_root.clone().try_into().unwrap()), + &[], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await + .unwrap(); + + let catalog = catalog_from_layer_stack(&stack); + let names = catalog + .personalities() + .iter() + .map(|personality| personality.name.as_str()) + .collect::>(); + assert_eq!( + names, + vec!["friendly", "none", "pragmatic", "alpha", "zebra"] + ); + } +} diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index f535465209..be32258faf 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -52,7 +52,7 @@ pub async fn maybe_migrate_personality( } ConfigEditsBuilder::new(codex_home) - .set_personality(Some(Personality::Pragmatic)) + .set_personality(Some(Personality::pragmatic())) .apply() .await .map_err(|err| { diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index de1070ad34..6fd58d28ad 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -81,7 +81,7 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> { assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + assert_eq!(persisted.personality, Some(Personality::pragmatic())); Ok(()) } @@ -102,7 +102,7 @@ async fn skips_when_marker_exists() -> io::Result<()> { async fn skips_when_personality_explicit() -> io::Result<()> { let temp = TempDir::new()?; ConfigEditsBuilder::new(temp.path()) - .set_personality(Some(Personality::Friendly)) + .set_personality(Some(Personality::friendly())) .apply() .await .map_err(|err| io::Error::other(format!("failed to write config: {err}")))?; @@ -117,7 +117,7 @@ async fn skips_when_personality_explicit() -> io::Result<()> { assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Friendly)); + assert_eq!(persisted.personality, Some(Personality::friendly())); Ok(()) } diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 2e33f03d88..6c4d9cdaa2 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -10,6 +10,7 @@ use crate::exec_policy::ExecPolicyManager; use crate::mcp::McpManager; use crate::mcp_connection_manager::McpConnectionManager; use crate::models_manager::manager::ModelsManager; +use crate::personalities::PersonalityCatalog; use crate::plugins::PluginsManager; use crate::skills_watcher::SkillsWatcher; use crate::state_db::StateDbHandle; @@ -44,6 +45,7 @@ pub(crate) struct SessionServices { pub(crate) exec_policy: Arc, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, + pub(crate) personality_catalog: Arc, pub(crate) session_telemetry: SessionTelemetry, pub(crate) tool_approvals: Mutex, pub(crate) skills_manager: Arc, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 808b04191d..d610b80185 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -247,7 +247,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 49f635432f..794838de89 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -106,7 +106,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Pragmatic); + config.personality = Some(Personality::pragmatic()); }); let test = builder.build(&server).await?; let preturn_context_diff_cwd = test.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); @@ -152,7 +152,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), }) .await?; wait_for_event(&test.codex, |event| { @@ -343,7 +343,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Pragmatic); + config.personality = Some(Personality::pragmatic()); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); @@ -365,7 +365,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), }) .await?; wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cc68c57156..e4cb05ad8a 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -48,7 +48,7 @@ async fn personality_does_not_mutate_base_instructions_without_template() { .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Friendly); + config.personality = Some(Personality::friendly()); let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.1", &config); assert_eq!( @@ -65,7 +65,7 @@ async fn base_instructions_override_disables_personality_template() { .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Friendly); + config.personality = Some(Personality::friendly()); config.base_instructions = Some("override instructions".to_string()); let model_info = @@ -141,7 +141,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Friendly); + config.personality = Some(Personality::friendly()); }); let test = builder.build(&server).await?; @@ -199,7 +199,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::None); + config.personality = Some(Personality::none()); }); let test = builder.build(&server).await?; @@ -353,7 +353,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), }) .await?; @@ -420,7 +420,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho .features .enable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Pragmatic); + config.personality = Some(Personality::pragmatic()); }); let test = builder.build(&server).await?; @@ -458,7 +458,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), }) .await?; @@ -510,7 +510,7 @@ async fn instructions_uses_base_if_feature_disabled() -> anyhow::Result<()> { .features .disable(Feature::Personality) .expect("test config should allow feature update"); - config.personality = Some(Personality::Friendly); + config.personality = Some(Personality::friendly()); let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.2-codex", &config); @@ -576,7 +576,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Pragmatic), + personality: Some(Personality::pragmatic()), }) .await?; @@ -691,7 +691,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow .enable(Feature::Personality) .expect("test config should allow feature update"); config.model = Some(remote_slug.to_string()); - config.personality = Some(Personality::Friendly); + config.personality = Some(Personality::friendly()); }); let test = builder.build(&server).await?; @@ -713,7 +713,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), }) .await?; @@ -850,7 +850,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - summary: None, service_tier: None, collaboration_mode: None, - personality: Some(Personality::Friendly), + personality: Some(Personality::friendly()), }) .await?; diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 0a8dd61d9c..d0df06edbd 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -183,7 +183,7 @@ async fn no_marker_sessions_sets_personality() -> io::Result<()> { ); let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + assert_eq!(persisted.personality, Some(Personality::pragmatic())); Ok(()) } @@ -199,7 +199,7 @@ async fn no_marker_sessions_preserves_existing_config_fields() -> io::Result<()> assert_eq!(status, PersonalityMigrationStatus::Applied); let persisted = read_config_toml(temp.path()).await?; assert_eq!(persisted.model, Some("gpt-5-codex".to_string())); - assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + assert_eq!(persisted.personality, Some(Personality::pragmatic())); Ok(()) } @@ -315,7 +315,7 @@ async fn applied_migration_is_idempotent_on_second_run() -> io::Result<()> { assert_eq!(first_status, PersonalityMigrationStatus::Applied); assert_eq!(second_status, PersonalityMigrationStatus::SkippedMarker); let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + assert_eq!(persisted.personality, Some(Personality::pragmatic())); Ok(()) } @@ -333,6 +333,6 @@ async fn no_marker_archived_sessions_sets_personality() -> io::Result<()> { ); let persisted = read_config_toml(temp.path()).await?; - assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + assert_eq!(persisted.personality, Some(Personality::pragmatic())); Ok(()) } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 9f238b40ee..1e92fba7cd 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -73,7 +73,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style -Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. +Built-in `personality` values are `friendly`, `pragmatic`, and `none`. Newer clients may also use custom personality ids backed by `.codex/personalities` or `$CODEX_HOME/personalities`; older servers reject unknown values. When `none` is selected, no personality instructions are injected. - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 52178296b6..30042bf134 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -2,10 +2,10 @@ use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::fmt; use std::num::NonZeroU64; use std::time::Duration; use strum_macros::Display; -use strum_macros::EnumIter; use ts_rs::TS; use crate::openai_models::ReasoningEffort; @@ -98,26 +98,80 @@ pub enum WindowsSandboxLevel { } #[derive( - Debug, - Serialize, - Deserialize, - Clone, - Copy, - PartialEq, - Eq, - Display, - JsonSchema, - TS, - PartialOrd, - Ord, - EnumIter, + Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS, PartialOrd, Ord, Hash, )] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum Personality { - None, - Friendly, - Pragmatic, +#[serde(transparent)] +#[ts(type = "string")] +pub struct Personality(String); + +impl Personality { + pub const NONE: &str = "none"; + pub const FRIENDLY: &str = "friendly"; + pub const PRAGMATIC: &str = "pragmatic"; + + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn is_builtin(&self) -> bool { + self.is_none() || self.is_friendly() || self.is_pragmatic() + } + + pub fn is_none(&self) -> bool { + self.as_str() == Self::NONE + } + + pub fn is_friendly(&self) -> bool { + self.as_str() == Self::FRIENDLY + } + + pub fn is_pragmatic(&self) -> bool { + self.as_str() == Self::PRAGMATIC + } + + pub fn builtin_names() -> [&'static str; 3] { + [Self::NONE, Self::FRIENDLY, Self::PRAGMATIC] + } + + pub fn none() -> Self { + Self::new(Self::NONE) + } + + pub fn friendly() -> Self { + Self::new(Self::FRIENDLY) + } + + pub fn pragmatic() -> Self { + Self::new(Self::PRAGMATIC) + } +} + +impl From<&str> for Personality { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for Personality { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From for String { + fn from(value: Personality) -> Self { + value.0 + } +} + +impl fmt::Display for Personality { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } } #[derive( diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 3c633cbd88..046ce4a95a 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -382,10 +382,14 @@ impl ModelInstructionsVariables { pub fn get_personality_message(&self, personality: Option) -> Option { if let Some(personality) = personality { - match personality { - Personality::None => Some(String::new()), - Personality::Friendly => self.personality_friendly.clone(), - Personality::Pragmatic => self.personality_pragmatic.clone(), + if personality.is_none() { + Some(String::new()) + } else if personality.is_friendly() { + self.personality_friendly.clone() + } else if personality.is_pragmatic() { + self.personality_pragmatic.clone() + } else { + None } } else { self.personality_default.clone() @@ -579,7 +583,7 @@ mod tests { instructions_variables: Some(personality_variables()), })); - let instructions = model.get_model_instructions(Some(Personality::Friendly)); + let instructions = model.get_model_instructions(Some(Personality::friendly())); assert_eq!(instructions, "Hello friendly"); } @@ -595,15 +599,15 @@ mod tests { }), })); assert_eq!( - model.get_model_instructions(Some(Personality::Friendly)), + model.get_model_instructions(Some(Personality::friendly())), "Hello\nfriendly" ); assert_eq!( - model.get_model_instructions(Some(Personality::Pragmatic)), + model.get_model_instructions(Some(Personality::pragmatic())), "Hello\n" ); assert_eq!( - model.get_model_instructions(Some(Personality::None)), + model.get_model_instructions(Some(Personality::none())), "Hello\n" ); assert_eq!( @@ -620,15 +624,15 @@ mod tests { }), })); assert_eq!( - model_no_personality.get_model_instructions(Some(Personality::Friendly)), + model_no_personality.get_model_instructions(Some(Personality::friendly())), "Hello\n" ); assert_eq!( - model_no_personality.get_model_instructions(Some(Personality::Pragmatic)), + model_no_personality.get_model_instructions(Some(Personality::pragmatic())), "Hello\n" ); assert_eq!( - model_no_personality.get_model_instructions(Some(Personality::None)), + model_no_personality.get_model_instructions(Some(Personality::none())), "Hello\n" ); assert_eq!( @@ -648,7 +652,7 @@ mod tests { }), })); - let instructions = model.get_model_instructions(Some(Personality::Friendly)); + let instructions = model.get_model_instructions(Some(Personality::friendly())); assert_eq!(instructions, "base"); } @@ -666,15 +670,15 @@ mod tests { fn get_personality_message() { let personality_variables = personality_variables(); assert_eq!( - personality_variables.get_personality_message(Some(Personality::Friendly)), + personality_variables.get_personality_message(Some(Personality::friendly())), Some("friendly".to_string()) ); assert_eq!( - personality_variables.get_personality_message(Some(Personality::Pragmatic)), + personality_variables.get_personality_message(Some(Personality::pragmatic())), Some("pragmatic".to_string()) ); assert_eq!( - personality_variables.get_personality_message(Some(Personality::None)), + personality_variables.get_personality_message(Some(Personality::none())), Some(String::new()) ); assert_eq!( @@ -688,15 +692,15 @@ mod tests { personality_pragmatic: None, }; assert_eq!( - personality_variables.get_personality_message(Some(Personality::Friendly)), + personality_variables.get_personality_message(Some(Personality::friendly())), None ); assert_eq!( - personality_variables.get_personality_message(Some(Personality::Pragmatic)), + personality_variables.get_personality_message(Some(Personality::pragmatic())), None ); assert_eq!( - personality_variables.get_personality_message(Some(Personality::None)), + personality_variables.get_personality_message(Some(Personality::none())), Some(String::new()) ); assert_eq!( @@ -710,15 +714,15 @@ mod tests { personality_pragmatic: Some("pragmatic".to_string()), }; assert_eq!( - personality_variables.get_personality_message(Some(Personality::Friendly)), + personality_variables.get_personality_message(Some(Personality::friendly())), Some("friendly".to_string()) ); assert_eq!( - personality_variables.get_personality_message(Some(Personality::Pragmatic)), + personality_variables.get_personality_message(Some(Personality::pragmatic())), Some("pragmatic".to_string()) ); assert_eq!( - personality_variables.get_personality_message(Some(Personality::None)), + personality_variables.get_personality_message(Some(Personality::none())), Some(String::new()) ); assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index add9e63741..a59a552251 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -63,6 +63,8 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PersonalitiesListParams; +use codex_app_server_protocol::PersonalitiesListResponse; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; @@ -1902,6 +1904,17 @@ impl App { }); } + fn fetch_personalities_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_personalities_list(request_handle, cwd.clone()) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::PersonalitiesLoaded { cwd, result }); + }); + } + fn fetch_plugin_detail( &mut self, app_server: &AppServerSession, @@ -2261,7 +2274,7 @@ impl App { *summary, *service_tier, collaboration_mode.clone(), - *personality, + personality.clone(), final_output_json_schema.clone(), ) .await?; @@ -4281,6 +4294,9 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::FetchPersonalitiesList { cwd } => { + self.fetch_personalities_list(app_server, cwd); + } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { @@ -4302,6 +4318,22 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::PersonalitiesLoaded { cwd, result } => match result { + Ok(response) => { + let personalities = response + .data + .into_iter() + .find(|entry| entry.cwd == cwd) + .map(|entry| entry.personalities) + .unwrap_or_default(); + self.chat_widget + .open_personality_popup_with_items(&personalities); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to load personalities: {err}")); + } + }, AppEvent::FetchPluginDetail { cwd, params } => { self.fetch_plugin_detail(app_server, cwd, params); } @@ -4864,12 +4896,12 @@ impl App { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) - .set_personality(Some(personality)) + .set_personality(Some(personality.clone())) .apply() .await { Ok(()) => { - let label = Self::personality_label(personality); + let label = Self::personality_label(&personality); let mut message = format!("Personality set to {label}"); if let Some(profile) = profile { message.push_str(" for "); @@ -5682,7 +5714,7 @@ impl App { } fn on_update_personality(&mut self, personality: Personality) { - self.config.personality = Some(personality); + self.config.personality = Some(personality.clone()); self.chat_widget.set_personality(personality); } @@ -5709,11 +5741,12 @@ impl App { } } - fn personality_label(personality: Personality) -> &'static str { - match personality { - Personality::None => "None", - Personality::Friendly => "Friendly", - Personality::Pragmatic => "Pragmatic", + fn personality_label(personality: &Personality) -> String { + match personality.as_str() { + Personality::NONE => "None".to_string(), + Personality::FRIENDLY => "Friendly".to_string(), + Personality::PRAGMATIC => "Pragmatic".to_string(), + _ => personality.as_str().to_string(), } } @@ -6018,6 +6051,23 @@ async fn fetch_plugins_list( .wrap_err("plugin/list failed in TUI") } +async fn fetch_personalities_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("personality list cwd must be absolute")?; + let request_id = RequestId::String(format!("personality-list-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PersonalitiesList { + request_id, + params: PersonalitiesListParams { + cwds: Some(vec![cwd]), + }, + }) + .await + .wrap_err("personalities/list failed in TUI") +} + async fn fetch_plugin_detail( request_handle: AppServerRequestHandle, params: PluginReadParams, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 76ca3b24f0..7b3c046cab 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PersonalitiesListResponse; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; @@ -183,12 +184,23 @@ pub(crate) enum AppEvent { cwd: PathBuf, }, + /// Fetch available personalities for the provided working directory. + FetchPersonalitiesList { + cwd: PathBuf, + }, + /// Result of fetching plugin marketplace state. PluginsLoaded { cwd: PathBuf, result: Result, }, + /// Result of fetching available personalities. + PersonalitiesLoaded { + cwd: PathBuf, + result: Result, + }, + /// Replace the plugins popup with a plugin-detail loading state. OpenPluginDetailLoading { plugin_display_name: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c83cd01e8a..328dcaa941 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -80,6 +80,7 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; +use codex_app_server_protocol::PersonalityMetadata; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -5847,8 +5848,9 @@ impl ChatWidget { let personality = self .config .personality + .clone() .filter(|_| self.config.features.enabled(Feature::Personality)) - .filter(|_| self.current_model_supports_personality()); + .filter(|personality| !personality.is_none()); let service_tier = self.config.service_tier.map(Some); let op = AppCommand::user_turn( items, @@ -7783,26 +7785,28 @@ impl ChatWidget { ); return; } - if !self.current_model_supports_personality() { - let current_model = self.current_model(); - self.add_error_message(format!( - "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." - )); - return; - } - self.open_personality_popup_for_current_model(); + self.app_event_tx.send(AppEvent::FetchPersonalitiesList { + cwd: self.config.cwd.to_path_buf(), + }); } - fn open_personality_popup_for_current_model(&mut self) { - let current_personality = self.config.personality.unwrap_or(Personality::Friendly); - let personalities = [Personality::Friendly, Personality::Pragmatic]; - let supports_personality = self.current_model_supports_personality(); + pub(crate) fn open_personality_popup_with_items( + &mut self, + personalities: &[PersonalityMetadata], + ) { + let current_personality = self + .config + .personality + .clone() + .unwrap_or_else(Personality::pragmatic); let items: Vec = personalities - .into_iter() + .iter() .map(|personality| { - let name = Self::personality_label(personality).to_string(); - let description = Some(Self::personality_description(personality).to_string()); + let personality_id = Personality::from(personality.name.clone()); + let name = Self::personality_label(&personality_id); + let description = Some(personality.description.clone()); + let selected_personality = personality_id.clone(); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( @@ -7816,18 +7820,19 @@ impl ChatWidget { /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, - Some(personality), + Some(selected_personality.clone()), ) .into_core(), )); - tx.send(AppEvent::UpdatePersonality(personality)); - tx.send(AppEvent::PersistPersonalitySelection { personality }); + tx.send(AppEvent::UpdatePersonality(selected_personality.clone())); + tx.send(AppEvent::PersistPersonalitySelection { + personality: selected_personality.clone(), + }); })]; SelectionItem { name, description, - is_current: current_personality == personality, - is_disabled: !supports_personality, + is_current: current_personality == personality_id, actions, dismiss_on_select: true, ..Default::default() @@ -9659,20 +9664,6 @@ impl ChatWidget { .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); } - fn current_model_supports_personality(&self) -> bool { - let model = self.current_model(); - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.supports_personality) - }) - .unwrap_or(false) - } - /// Return whether the effective model currently advertises image-input support. /// /// We intentionally default to `true` when model metadata cannot be read so transient catalog @@ -9821,19 +9812,12 @@ impl ChatWidget { self.bottom_pane.set_collaboration_mode_indicator(indicator); } - fn personality_label(personality: Personality) -> &'static str { - match personality { - Personality::None => "None", - Personality::Friendly => "Friendly", - Personality::Pragmatic => "Pragmatic", - } - } - - fn personality_description(personality: Personality) -> &'static str { - match personality { - Personality::None => "No personality instructions.", - Personality::Friendly => "Warm, collaborative, and helpful.", - Personality::Pragmatic => "Concise, task-focused, and direct.", + fn personality_label(personality: &Personality) -> String { + match personality.as_str() { + "none" => "None".to_string(), + "friendly" => "Friendly".to_string(), + "pragmatic" => "Pragmatic".to_string(), + other => other.to_string(), } } diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index ddeaf39cd0..c6f8bc5a36 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -50,6 +50,8 @@ Properties/methods: - `thread_unarchive(thread_id: str) -> Thread` - `models(*, include_hidden: bool = False) -> ModelListResponse` +`personality` accepts either a raw string id or the built-in `Personality` enum values (`Personality.FRIENDLY`, `Personality.PRAGMATIC`, `Personality.NONE`). + Context manager: ```python diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 6685fd0999..a5364bef66 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -452,9 +452,22 @@ def generate_v2_all() -> None: ], cwd=sdk_root(), ) + _widen_generated_personality_type(out_path) _normalize_generated_timestamps(out_path) +def _widen_generated_personality_type(out_path: Path) -> None: + source = out_path.read_text() + updated = re.sub( + r"class Personality\(Enum\):\n(?: .+\n)+", + "Personality = str\n\n", + source, + count=1, + ) + if updated != source: + out_path.write_text(updated) + + def _notification_specs() -> list[tuple[str, str]]: server_notifications = json.loads( (schema_root_dir() / "ServerNotification.json").read_text() @@ -544,6 +557,11 @@ FIELD_ANNOTATION_OVERRIDES: dict[str, str] = { # Keep public API typed without falling back to `Any`. "config": "JsonObject", "output_schema": "JsonObject", + "personality": "PersonalityLike", +} + +FIELD_SERIALIZATION_OVERRIDES: dict[str, str] = { + "personality": "personality_value(personality)", } @@ -636,7 +654,11 @@ def _kw_signature_lines(fields: list[PublicFieldSpec]) -> list[str]: def _model_arg_lines( fields: list[PublicFieldSpec], *, indent: str = " " ) -> list[str]: - return [f"{indent}{field.wire_name}={field.py_name}," for field in fields] + lines = [] + for field in fields: + value = FIELD_SERIALIZATION_OVERRIDES.get(field.wire_name, field.py_name) + lines.append(f"{indent}{field.wire_name}={value},") + return lines def _replace_generated_block(source: str, block_name: str, body: str) -> str: diff --git a/sdk/python/src/codex_app_server/__init__.py b/sdk/python/src/codex_app_server/__init__.py index c35ce0ebe5..86b5cdaa82 100644 --- a/sdk/python/src/codex_app_server/__init__.py +++ b/sdk/python/src/codex_app_server/__init__.py @@ -16,7 +16,6 @@ from .errors import ( ) from .generated.v2_all import ( AskForApproval, - Personality, PlanType, ReasoningEffort, ReasoningSummary, @@ -36,6 +35,7 @@ from .generated.v2_all import ( TurnStatus, TurnSteerParams, ) +from .personality import Personality from .models import InitializeResponse from .api import ( AsyncCodex, diff --git a/sdk/python/src/codex_app_server/api.py b/sdk/python/src/codex_app_server/api.py index 5009d9bbf5..57e9fd7802 100644 --- a/sdk/python/src/codex_app_server/api.py +++ b/sdk/python/src/codex_app_server/api.py @@ -10,7 +10,6 @@ from .generated.v2_all import ( ApprovalsReviewer, AskForApproval, ModelListResponse, - Personality, ReasoningEffort, ReasoningSummary, SandboxMode, @@ -34,6 +33,9 @@ from .generated.v2_all import ( TurnSteerResponse, ) from .models import InitializeResponse, JsonObject, Notification, ServerInfo +from .personality import Personality +from .personality import PersonalityLike +from .personality import personality_value from ._inputs import ( ImageInput, Input, @@ -142,7 +144,7 @@ class Codex: ephemeral: bool | None = None, model: str | None = None, model_provider: str | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox: SandboxMode | None = None, service_name: str | None = None, service_tier: ServiceTier | None = None, @@ -157,7 +159,7 @@ class Codex: ephemeral=ephemeral, model=model, model_provider=model_provider, - personality=personality, + personality=personality_value(personality), sandbox=sandbox, service_name=service_name, service_tier=service_tier, @@ -201,7 +203,7 @@ class Codex: developer_instructions: str | None = None, model: str | None = None, model_provider: str | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox: SandboxMode | None = None, service_tier: ServiceTier | None = None, ) -> Thread: @@ -215,7 +217,7 @@ class Codex: developer_instructions=developer_instructions, model=model, model_provider=model_provider, - personality=personality, + personality=personality_value(personality), sandbox=sandbox, service_tier=service_tier, ) @@ -332,7 +334,7 @@ class AsyncCodex: ephemeral: bool | None = None, model: str | None = None, model_provider: str | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox: SandboxMode | None = None, service_name: str | None = None, service_tier: ServiceTier | None = None, @@ -348,7 +350,7 @@ class AsyncCodex: ephemeral=ephemeral, model=model, model_provider=model_provider, - personality=personality, + personality=personality_value(personality), sandbox=sandbox, service_name=service_name, service_tier=service_tier, @@ -393,7 +395,7 @@ class AsyncCodex: developer_instructions: str | None = None, model: str | None = None, model_provider: str | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox: SandboxMode | None = None, service_tier: ServiceTier | None = None, ) -> AsyncThread: @@ -408,7 +410,7 @@ class AsyncCodex: developer_instructions=developer_instructions, model=model, model_provider=model_provider, - personality=personality, + personality=personality_value(personality), sandbox=sandbox, service_tier=service_tier, ) @@ -479,7 +481,7 @@ class Thread: effort: ReasoningEffort | None = None, model: str | None = None, output_schema: JsonObject | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox_policy: SandboxPolicy | None = None, service_tier: ServiceTier | None = None, summary: ReasoningSummary | None = None, @@ -492,7 +494,7 @@ class Thread: effort=effort, model=model, output_schema=output_schema, - personality=personality, + personality=personality_value(personality), sandbox_policy=sandbox_policy, service_tier=service_tier, summary=summary, @@ -514,7 +516,7 @@ class Thread: effort: ReasoningEffort | None = None, model: str | None = None, output_schema: JsonObject | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox_policy: SandboxPolicy | None = None, service_tier: ServiceTier | None = None, summary: ReasoningSummary | None = None, @@ -529,7 +531,7 @@ class Thread: effort=effort, model=model, output_schema=output_schema, - personality=personality, + personality=personality_value(personality), sandbox_policy=sandbox_policy, service_tier=service_tier, summary=summary, @@ -563,7 +565,7 @@ class AsyncThread: effort: ReasoningEffort | None = None, model: str | None = None, output_schema: JsonObject | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox_policy: SandboxPolicy | None = None, service_tier: ServiceTier | None = None, summary: ReasoningSummary | None = None, @@ -576,7 +578,7 @@ class AsyncThread: effort=effort, model=model, output_schema=output_schema, - personality=personality, + personality=personality_value(personality), sandbox_policy=sandbox_policy, service_tier=service_tier, summary=summary, @@ -598,7 +600,7 @@ class AsyncThread: effort: ReasoningEffort | None = None, model: str | None = None, output_schema: JsonObject | None = None, - personality: Personality | None = None, + personality: PersonalityLike | None = None, sandbox_policy: SandboxPolicy | None = None, service_tier: ServiceTier | None = None, summary: ReasoningSummary | None = None, @@ -614,7 +616,7 @@ class AsyncThread: effort=effort, model=model, output_schema=output_schema, - personality=personality, + personality=personality_value(personality), sandbox_policy=sandbox_policy, service_tier=service_tier, summary=summary, diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index 21d4968c33..6be3c62ccb 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -1358,6 +1358,16 @@ class ChatgptLoginAccountParams(BaseModel): ] +class ChatgptDeviceCodeLoginAccountParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Annotated[ + Literal["chatgptDeviceCode"], + Field(title="ChatgptDeviceCodev2::LoginAccountParamsType"), + ] + + class ChatgptAuthTokensLoginAccountParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1393,6 +1403,7 @@ class LoginAccountParams( RootModel[ ApiKeyLoginAccountParams | ChatgptLoginAccountParams + | ChatgptDeviceCodeLoginAccountParams | ChatgptAuthTokensLoginAccountParams ] ): @@ -1402,6 +1413,7 @@ class LoginAccountParams( root: Annotated[ ApiKeyLoginAccountParams | ChatgptLoginAccountParams + | ChatgptDeviceCodeLoginAccountParams | ChatgptAuthTokensLoginAccountParams, Field(title="LoginAccountParams"), ] @@ -1433,6 +1445,31 @@ class ChatgptLoginAccountResponse(BaseModel): ] +class ChatgptDeviceCodeLoginAccountResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + login_id: Annotated[str, Field(alias="loginId")] + type: Annotated[ + Literal["chatgptDeviceCode"], + Field(title="ChatgptDeviceCodev2::LoginAccountResponseType"), + ] + user_code: Annotated[ + str, + Field( + alias="userCode", + description="One-time code the user must enter after signing in.", + ), + ] + verification_url: Annotated[ + str, + Field( + alias="verificationUrl", + description="URL the client should open in a browser to complete device code authorization.", + ), + ] + + class ChatgptAuthTokensLoginAccountResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -1447,6 +1484,7 @@ class LoginAccountResponse( RootModel[ ApiKeyLoginAccountResponse | ChatgptLoginAccountResponse + | ChatgptDeviceCodeLoginAccountResponse | ChatgptAuthTokensLoginAccountResponse ] ): @@ -1456,6 +1494,7 @@ class LoginAccountResponse( root: Annotated[ ApiKeyLoginAccountResponse | ChatgptLoginAccountResponse + | ChatgptDeviceCodeLoginAccountResponse | ChatgptAuthTokensLoginAccountResponse, Field(title="LoginAccountResponse"), ] @@ -1658,28 +1697,74 @@ class NetworkAccess(Enum): enabled = "enabled" +class NetworkDomainPermission(Enum): + allow = "allow" + deny = "deny" + + class NetworkRequirements(BaseModel): model_config = ConfigDict( populate_by_name=True, ) allow_local_binding: Annotated[bool | None, Field(alias="allowLocalBinding")] = None - allow_unix_sockets: Annotated[list[str] | None, Field(alias="allowUnixSockets")] = ( - None - ) + allow_unix_sockets: Annotated[ + list[str] | None, + Field( + alias="allowUnixSockets", + description="Legacy compatibility view derived from `unix_sockets`.", + ), + ] = None allow_upstream_proxy: Annotated[bool | None, Field(alias="allowUpstreamProxy")] = ( None ) - allowed_domains: Annotated[list[str] | None, Field(alias="allowedDomains")] = None + allowed_domains: Annotated[ + list[str] | None, + Field( + alias="allowedDomains", + description="Legacy compatibility view derived from `domains`.", + ), + ] = None dangerously_allow_all_unix_sockets: Annotated[ bool | None, Field(alias="dangerouslyAllowAllUnixSockets") ] = None dangerously_allow_non_loopback_proxy: Annotated[ bool | None, Field(alias="dangerouslyAllowNonLoopbackProxy") ] = None - denied_domains: Annotated[list[str] | None, Field(alias="deniedDomains")] = None + denied_domains: Annotated[ + list[str] | None, + Field( + alias="deniedDomains", + description="Legacy compatibility view derived from `domains`.", + ), + ] = None + domains: Annotated[ + dict[str, Any] | None, + Field( + description="Canonical network permission map for `experimental_network`." + ), + ] = None enabled: bool | None = None http_port: Annotated[int | None, Field(alias="httpPort", ge=0)] = None + managed_allowed_domains_only: Annotated[ + bool | None, + Field( + alias="managedAllowedDomainsOnly", + description="When true, only managed allowlist entries are respected while managed network enforcement is active.", + ), + ] = None socks_port: Annotated[int | None, Field(alias="socksPort", ge=0)] = None + unix_sockets: Annotated[ + dict[str, Any] | None, + Field( + alias="unixSockets", + description="Canonical unix socket permission map for `experimental_network`.", + ), + ] = None + + +class NetworkUnixSocketPermission(Enum): + allow = "allow" + none = "none" class NonSteerableTurnKind(Enum): @@ -1725,10 +1810,17 @@ class PatchChangeKind( root: AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind -class Personality(Enum): - none = "none" - friendly = "friendly" - pragmatic = "pragmatic" +class PersonalitiesListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwds: list[AbsolutePathBuf] | None = None + + +class PersonalityScope(Enum): + builtin = "builtin" + user = "user" + repo = "repo" class PlanDeltaNotification(BaseModel): @@ -3265,7 +3357,7 @@ class ThreadResumeParams(BaseModel): Field(description="Configuration overrides for the resumed thread, if any."), ] = None model_provider: Annotated[str | None, Field(alias="modelProvider")] = None - personality: Personality | None = None + personality: str | None = None sandbox: SandboxMode | None = None service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None thread_id: Annotated[str, Field(alias="threadId")] @@ -3362,7 +3454,7 @@ class ThreadStartParams(BaseModel): ephemeral: bool | None = None model: str | None = None model_provider: Annotated[str | None, Field(alias="modelProvider")] = None - personality: Personality | None = None + personality: str | None = None sandbox: SandboxMode | None = None service_name: Annotated[str | None, Field(alias="serviceName")] = None service_tier: Annotated[ServiceTier | None, Field(alias="serviceTier")] = None @@ -3943,6 +4035,17 @@ class SkillsListRequest(BaseModel): params: SkillsListParams +class PersonalitiesListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["personalities/list"], Field(title="Personalities/listRequestMethod") + ] + params: PersonalitiesListParams + + class PluginListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4873,6 +4976,16 @@ class OverriddenMetadata(BaseModel): overriding_layer: Annotated[ConfigLayerMetadata, Field(alias="overridingLayer")] +class PersonalityMetadata(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + is_built_in: Annotated[bool, Field(alias="isBuiltIn")] + name: str + scope: PersonalityScope + + class PluginDetail(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5551,7 +5664,7 @@ class TurnStartParams(BaseModel): ), ] = None personality: Annotated[ - Personality | None, + str | None, Field( description="Override the personality for this turn and subsequent turns." ), @@ -5861,6 +5974,21 @@ class ListMcpServerStatusResponse(BaseModel): ] = None +class PersonalitiesListEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cwd: str + personalities: list[PersonalityMetadata] + + +class PersonalitiesListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[PersonalitiesListEntry] + + class PluginListResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6152,6 +6280,7 @@ class ClientRequest( | ThreadLoadedListRequest | ThreadReadRequest | SkillsListRequest + | PersonalitiesListRequest | PluginListRequest | PluginReadRequest | AppListRequest @@ -6217,6 +6346,7 @@ class ClientRequest( | ThreadLoadedListRequest | ThreadReadRequest | SkillsListRequest + | PersonalitiesListRequest | PluginListRequest | PluginReadRequest | AppListRequest diff --git a/sdk/python/src/codex_app_server/personality.py b/sdk/python/src/codex_app_server/personality.py new file mode 100644 index 0000000000..ad59e36fae --- /dev/null +++ b/sdk/python/src/codex_app_server/personality.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from enum import Enum + + +class Personality(str, Enum): + NONE = "none" + FRIENDLY = "friendly" + PRAGMATIC = "pragmatic" + + +PersonalityLike = str | Personality + + +def personality_value(personality: PersonalityLike | None) -> str | None: + if isinstance(personality, Personality): + return personality.value + return personality