mirror of
https://github.com/openai/codex.git
synced 2026-08-26 13:38:49 +00:00
## What changed - Add optional `icon` and `color` appearance fields to custom thread sections and expose them through the app-server protocol. - Persist appearance metadata in SQLite and include it when listing sections or returning threads. - Let `threadSection/update` preserve an omitted appearance, clear it with `null`, or replace it with a new value. - Reject appearance fields larger than 64 bytes. ## Testing - Cover protocol compatibility, persistence across restart, update and clear behavior, and field-length validation. GitOrigin-RevId: 8f55ce4f42f8bd17aa5416848a85845f681953df
235 lines
8.5 KiB
Rust
235 lines
8.5 KiB
Rust
use super::thread_processor::THREAD_LIST_DEFAULT_LIMIT;
|
|
use super::thread_processor::THREAD_LIST_MAX_LIMIT;
|
|
use super::thread_processor::ThreadRequestProcessor;
|
|
use crate::error_code::internal_error;
|
|
use crate::error_code::invalid_params;
|
|
use crate::error_code::method_not_found;
|
|
use codex_app_server_protocol::ClientResponsePayload;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::ThreadSection;
|
|
use codex_app_server_protocol::ThreadSectionAppearance;
|
|
use codex_app_server_protocol::ThreadSectionCreateParams;
|
|
use codex_app_server_protocol::ThreadSectionCreateResponse;
|
|
use codex_app_server_protocol::ThreadSectionDeleteParams;
|
|
use codex_app_server_protocol::ThreadSectionDeleteResponse;
|
|
use codex_app_server_protocol::ThreadSectionListParams;
|
|
use codex_app_server_protocol::ThreadSectionListResponse;
|
|
use codex_app_server_protocol::ThreadSectionUpdateParams;
|
|
use codex_app_server_protocol::ThreadSectionUpdateResponse;
|
|
use codex_state::PINNED_THREAD_SECTION_ID;
|
|
use codex_thread_store::CreateThreadSectionParams as StoreCreateThreadSectionParams;
|
|
use codex_thread_store::DeleteThreadSectionParams as StoreDeleteThreadSectionParams;
|
|
use codex_thread_store::ListThreadSectionsParams as StoreListThreadSectionsParams;
|
|
use codex_thread_store::RenameThreadSectionParams as StoreRenameThreadSectionParams;
|
|
use codex_thread_store::StoredThreadSection;
|
|
use codex_thread_store::ThreadStoreError;
|
|
|
|
const MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES: usize = 64;
|
|
|
|
impl ThreadRequestProcessor {
|
|
pub(crate) async fn thread_section_list(
|
|
&self,
|
|
params: ThreadSectionListParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
const OPERATION: &str = "threadSection/list";
|
|
self.ensure_thread_sections_supported(OPERATION)?;
|
|
let limit = params
|
|
.limit
|
|
.map(|value| value as usize)
|
|
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
|
|
.clamp(1, THREAD_LIST_MAX_LIMIT);
|
|
let page = self
|
|
.thread_store
|
|
.list_thread_sections(StoreListThreadSectionsParams {
|
|
cursor: params.cursor,
|
|
limit,
|
|
})
|
|
.await
|
|
.map_err(|err| thread_section_store_error(OPERATION, err))?;
|
|
|
|
Ok(Some(
|
|
ThreadSectionListResponse {
|
|
data: page.sections.into_iter().map(api_thread_section).collect(),
|
|
next_cursor: page.next_cursor,
|
|
}
|
|
.into(),
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn thread_section_create(
|
|
&self,
|
|
params: ThreadSectionCreateParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
const OPERATION: &str = "threadSection/create";
|
|
self.ensure_thread_sections_supported(OPERATION)?;
|
|
let name = params.name.trim();
|
|
if name.is_empty() {
|
|
return Err(invalid_params("section name must not be empty"));
|
|
}
|
|
if let Some(appearance) = params.appearance.as_ref() {
|
|
validate_thread_section_appearance(appearance)?;
|
|
}
|
|
let section = self
|
|
.thread_store
|
|
.create_thread_section(StoreCreateThreadSectionParams {
|
|
name: name.to_string(),
|
|
appearance: params.appearance.map(state_thread_section_appearance),
|
|
})
|
|
.await
|
|
.map_err(|err| thread_section_store_error(OPERATION, err))?;
|
|
|
|
Ok(Some(
|
|
ThreadSectionCreateResponse {
|
|
section: api_thread_section(section),
|
|
}
|
|
.into(),
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn thread_section_update(
|
|
&self,
|
|
params: ThreadSectionUpdateParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
const OPERATION: &str = "threadSection/update";
|
|
self.ensure_thread_sections_supported(OPERATION)?;
|
|
let name = params.name.trim();
|
|
if name.is_empty() {
|
|
return Err(invalid_params("section name must not be empty"));
|
|
}
|
|
if params.section_id.trim().is_empty() {
|
|
return Err(invalid_params("sectionId must not be empty"));
|
|
}
|
|
if params.section_id == PINNED_THREAD_SECTION_ID {
|
|
return Err(invalid_params(
|
|
"the built-in pinned section cannot be renamed",
|
|
));
|
|
}
|
|
if let Some(Some(appearance)) = params.appearance.as_ref() {
|
|
validate_thread_section_appearance(appearance)?;
|
|
}
|
|
let section = self
|
|
.thread_store
|
|
.rename_thread_section(StoreRenameThreadSectionParams {
|
|
section_id: params.section_id.clone(),
|
|
name: name.to_string(),
|
|
appearance: params
|
|
.appearance
|
|
.map(|appearance| appearance.map(state_thread_section_appearance)),
|
|
})
|
|
.await
|
|
.map_err(|err| thread_section_store_error(OPERATION, err))?
|
|
.ok_or_else(|| {
|
|
invalid_params(format!("thread section not found: {}", params.section_id))
|
|
})?;
|
|
|
|
Ok(Some(
|
|
ThreadSectionUpdateResponse {
|
|
section: api_thread_section(section),
|
|
}
|
|
.into(),
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn thread_section_delete(
|
|
&self,
|
|
params: ThreadSectionDeleteParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
const OPERATION: &str = "threadSection/delete";
|
|
self.ensure_thread_sections_supported(OPERATION)?;
|
|
if params.section_id.trim().is_empty() {
|
|
return Err(invalid_params("sectionId must not be empty"));
|
|
}
|
|
if params.section_id == PINNED_THREAD_SECTION_ID {
|
|
return Err(invalid_params(
|
|
"the built-in pinned section cannot be deleted",
|
|
));
|
|
}
|
|
let deleted = self
|
|
.thread_store
|
|
.delete_thread_section(StoreDeleteThreadSectionParams {
|
|
section_id: params.section_id.clone(),
|
|
})
|
|
.await
|
|
.map_err(|err| thread_section_store_error(OPERATION, err))?;
|
|
if !deleted {
|
|
return Err(invalid_params(format!(
|
|
"thread section not found: {}",
|
|
params.section_id
|
|
)));
|
|
}
|
|
|
|
Ok(Some(ThreadSectionDeleteResponse {}.into()))
|
|
}
|
|
|
|
fn ensure_thread_sections_supported(
|
|
&self,
|
|
operation: &'static str,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
if self.thread_store.supports_thread_sections() {
|
|
Ok(())
|
|
} else {
|
|
Err(unsupported_thread_section_operation(operation))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_thread_section_appearance(
|
|
appearance: &ThreadSectionAppearance,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
for (field, value) in [
|
|
("icon", appearance.icon.as_ref()),
|
|
("color", appearance.color.as_ref()),
|
|
] {
|
|
if value.is_some_and(|value| value.len() > MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES) {
|
|
return Err(invalid_params(format!(
|
|
"section appearance {field} must not exceed {MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES} bytes"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn api_thread_section(section: StoredThreadSection) -> ThreadSection {
|
|
ThreadSection {
|
|
id: section.id,
|
|
name: section.name,
|
|
appearance: section
|
|
.appearance
|
|
.map(|appearance| ThreadSectionAppearance {
|
|
icon: appearance.icon,
|
|
color: appearance.color,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn state_thread_section_appearance(
|
|
appearance: ThreadSectionAppearance,
|
|
) -> codex_state::ThreadSectionAppearance {
|
|
codex_state::ThreadSectionAppearance {
|
|
icon: appearance.icon,
|
|
color: appearance.color,
|
|
}
|
|
}
|
|
|
|
fn unsupported_thread_section_operation(operation: &'static str) -> JSONRPCErrorError {
|
|
method_not_found(format!("{operation} is unavailable without sqlite state"))
|
|
}
|
|
|
|
fn thread_section_store_error(
|
|
operation: &'static str,
|
|
error: ThreadStoreError,
|
|
) -> JSONRPCErrorError {
|
|
match error {
|
|
ThreadStoreError::Unsupported { .. } => unsupported_thread_section_operation(operation),
|
|
ThreadStoreError::InvalidRequest { message } => invalid_params(message),
|
|
error @ (ThreadStoreError::ThreadNotFound { .. }
|
|
| ThreadStoreError::Conflict { .. }
|
|
| ThreadStoreError::Internal { .. }) => {
|
|
let action = operation
|
|
.strip_prefix("threadSection/")
|
|
.unwrap_or(operation);
|
|
internal_error(format!("failed to {action} thread section: {error}"))
|
|
}
|
|
}
|
|
}
|