Add file-backed personality support and listing

This commit is contained in:
Eric Traut
2026-03-31 17:06:45 -06:00
parent ae057e0bb9
commit 8e00fec700
61 changed files with 1741 additions and 366 deletions
+1
View File
@@ -1898,6 +1898,7 @@ dependencies = [
"seccompiler",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"sha1",
"shlex",
@@ -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": {
@@ -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": [
@@ -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": [
@@ -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"
}
@@ -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"
}
@@ -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": {
@@ -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": {
@@ -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": [
File diff suppressed because one or more lines are too long
@@ -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;
@@ -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<PersonalityMetadata>, };
@@ -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<AbsolutePathBuf> | null, };
@@ -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<PersonalitiesListEntry>, };
@@ -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, };
@@ -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";
@@ -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";
@@ -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,
@@ -3182,6 +3182,21 @@ pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>,
}
#[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<Vec<AbsolutePathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PersonalitiesListResponse {
pub data: Vec<PersonalitiesListEntry>,
}
#[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<SkillErrorInfo>,
}
#[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<PersonalityMetadata>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+27 -1
View File
@@ -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 <feature_name> > 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
@@ -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::<Vec<_>>()
})
.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<codex_app_server_protocol::PersonalityMetadata> {
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<PathBuf>,
@@ -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<i64> {
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,
@@ -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;
@@ -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::<Vec<_>>();
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(())
}
@@ -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?;
@@ -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()
+1
View File
@@ -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 }
+3 -15
View File
@@ -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"
+20 -14
View File
@@ -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(),
}
}
@@ -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,
+21 -16
View File
@@ -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("<personality_spec>"))
}))
}) && 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
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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,
};
+4 -4
View File
@@ -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,
+3 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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<i64> {
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),
};
+28 -8
View File
@@ -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<DeveloperInstructions> {
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<String> {
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<ResponseItem> {
// 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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}),
+1
View File
@@ -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;
+487
View File
@@ -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<String>,
pub scope: PersonalityScope,
pub is_builtin: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PersonalityCatalog {
personalities: Vec<PersonalityDefinition>,
by_name: HashMap<Personality, PersonalityDefinition>,
}
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<PersonalityDefinition>,
errors: Vec<PersonalityError>,
}
#[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<Personality> = 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<PersonalityDefinition> {
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<Personality>,
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<PersonalityDefinition, PersonalityParseError> {
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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>();
assert_eq!(
names,
vec!["friendly", "none", "pragmatic", "alpha", "zebra"]
);
}
}
+1 -1
View File
@@ -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| {
@@ -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(())
}
+2
View File
@@ -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<ExecPolicyManager>,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) personality_catalog: Arc<PersonalityCatalog>,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) skills_manager: Arc<SkillsManager>,
+1 -1
View File
@@ -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?;
@@ -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| {
+12 -12
View File
@@ -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?;
@@ -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(())
}
+1 -1
View File
@@ -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`
+74 -20
View File
@@ -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<String>) -> 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<String> for Personality {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<Personality> 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(
+25 -21
View File
@@ -382,10 +382,14 @@ impl ModelInstructionsVariables {
pub fn get_personality_message(&self, personality: Option<Personality>) -> Option<String> {
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!(
+59 -9
View File
@@ -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<PersonalitiesListResponse> {
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,
+12
View File
@@ -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<PluginListResponse, String>,
},
/// Result of fetching available personalities.
PersonalitiesLoaded {
cwd: PathBuf,
result: Result<PersonalitiesListResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
+32 -48
View File
@@ -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<SelectionItem> = 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<SelectionAction> = 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(),
}
}