diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 563206258c3a..9799625b4cc7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -204,6 +204,7 @@ fn sample_thread_with_metadata( parent_thread_id, preview: "first prompt".to_string(), ephemeral, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 41ff883b5050..ba76199a69a2 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -478,6 +478,7 @@ fn sample_thread(thread_id: &str) -> Thread { parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7821bb11b0a7..d4bec1c5034e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4037,6 +4037,13 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "isPinned": { + "description": "Optional pinned filter; when set, only threads matching this value are returned.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -4169,6 +4176,13 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, + "isPinned": { + "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "type": [ + "boolean", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 3fbb2df3e492..47abdaf97a45 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3763,6 +3763,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 27446544f466..418e8f12ed18 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -17966,6 +17966,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -19410,6 +19415,13 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "isPinned": { + "description": "Optional pinned filter; when set, only threads matching this value are returned.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -19600,6 +19612,13 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, + "isPinned": { + "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "type": [ + "boolean", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 3960c8e0eb3f..bc22245be548 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -15730,6 +15730,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" @@ -17174,6 +17179,13 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "isPinned": { + "description": "Optional pinned filter; when set, only threads matching this value are returned.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -17364,6 +17376,13 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, + "isPinned": { + "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "type": [ + "boolean", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index c69777948649..e1d0d8d2e8d0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1110,6 +1110,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 11f7da47d380..342b3c1a4c2b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -71,6 +71,13 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "isPinned": { + "description": "Optional pinned filter; when set, only threads matching this value are returned.", + "type": [ + "boolean", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 5b64c33981da..09fee6b7081e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -901,6 +901,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json index c6679568ea5e..edba5e6c60fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json @@ -40,6 +40,13 @@ ], "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." }, + "isPinned": { + "description": "Patch whether this thread is pinned. Omit to leave the stored value unchanged.", + "type": [ + "boolean", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 7423f89fd7d0..172273875e33 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -901,6 +901,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index ed061e1616ee..67683a028482 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -901,6 +901,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index df2fd0218b32..e404d553165a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1110,6 +1110,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 4264629db423..2452b0867ec2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -901,6 +901,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 5d40babf4dc2..c0912787fe16 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1110,6 +1110,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 3b0288ebcc00..421d8011dc00 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -901,6 +901,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 3fa6217a3afd..a95e2ff72516 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -901,6 +901,11 @@ "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", "type": "string" }, + "isPinned": { + "default": false, + "description": "Whether the thread has been pinned by the user.", + "type": "boolean" + }, "modelProvider": { "description": "Model provider used for this thread (for example, 'openai').", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 49e2411f4231..1944cbd67bde 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -27,6 +27,9 @@ preview: string, /** * Whether the thread is ephemeral and should not be materialized on disk. */ ephemeral: boolean, /** + * Whether the thread has been pinned by the user. + */ +isPinned: boolean, /** * Model provider used for this thread (for example, 'openai'). */ modelProvider: string, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index ce2539af0cc3..7d611ddc96e5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -30,6 +30,9 @@ sourceKinds?: Array | null, /** * If false or null, only non-archived threads are returned. */ archived?: boolean | null, /** + * Optional pinned filter; when set, only threads matching this value are returned. + */ +isPinned?: boolean | null, /** * Optional cwd filter or filters; when set, only threads whose session cwd * exactly matches one of these paths are returned. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts index bec4bc1284d0..4e670e847199 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts @@ -9,4 +9,8 @@ export type ThreadMetadataUpdateParams = { threadId: string, * Omit a field to leave it unchanged, set it to `null` to clear it, or * provide a string to replace the stored value. */ -gitInfo?: ThreadMetadataGitInfoUpdateParams | null, }; +gitInfo?: ThreadMetadataGitInfoUpdateParams | null, +/** + * Patch whether this thread is pinned. Omit to leave the stored value unchanged. + */ +isPinned?: boolean | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 0021b29b785d..359eb65efa6b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2725,6 +2725,7 @@ mod tests { parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: true, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, @@ -2777,6 +2778,7 @@ mod tests { "parentThreadId": null, "preview": "first prompt", "ephemeral": true, + "isPinned": false, "historyMode": "legacy", "modelProvider": "openai", "createdAt": 1, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7b38dceb2d85..ecd3db1e80dd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -185,6 +185,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { parent_thread_id: None, preview: String::new(), ephemeral: false, + is_pinned: true, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, @@ -225,6 +226,17 @@ fn thread_resume_response_round_trips_initial_turns_page() { }; let value = serde_json::to_value(&response).expect("serialize thread resume response"); + assert_eq!(value["thread"]["isPinned"], json!(true)); + + let mut legacy_thread = value["thread"].clone(); + legacy_thread + .as_object_mut() + .expect("serialized thread should be an object") + .remove("isPinned"); + let legacy_thread = + serde_json::from_value::(legacy_thread).expect("deserialize legacy thread"); + assert!(!legacy_thread.is_pinned); + assert_eq!( value.get("initialTurnsPage"), Some(&json!({ @@ -356,6 +368,42 @@ fn thread_list_params_accepts_state_db_only_flag() { assert!(params.use_state_db_only); } +#[test] +fn thread_list_params_accepts_pinned_filter() { + for is_pinned in [true, false] { + let params = serde_json::from_value::(json!({ + "isPinned": is_pinned, + })) + .expect("pinned filter should deserialize"); + + assert_eq!(params.is_pinned, Some(is_pinned)); + } + + let params = serde_json::from_value::(json!({})) + .expect("omitted pinned filter should deserialize"); + assert_eq!(params.is_pinned, None); +} + +#[test] +fn thread_metadata_update_params_accepts_pinned_patch() { + for is_pinned in [true, false] { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "isPinned": is_pinned, + })) + .expect("pinned metadata patch should deserialize"); + + assert_eq!(params.is_pinned, Some(is_pinned)); + assert_eq!(params.git_info, None); + } + + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + })) + .expect("omitted pinned metadata patch should deserialize"); + assert_eq!(params.is_pinned, None); +} + #[test] fn collab_agent_state_maps_interrupted_status() { assert_eq!( diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index acebeb546ac6..966f45aa497a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -865,6 +865,9 @@ pub struct ThreadMetadataUpdateParams { /// provide a string to replace the stored value. #[ts(optional = nullable)] pub git_info: Option, + /// Patch whether this thread is pinned. Omit to leave the stored value unchanged. + #[ts(optional = nullable)] + pub is_pinned: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1118,6 +1121,9 @@ pub struct ThreadListParams { /// If false or null, only non-archived threads are returned. #[ts(optional = nullable)] pub archived: Option, + /// Optional pinned filter; when set, only threads matching this value are returned. + #[ts(optional = nullable)] + pub is_pinned: Option, /// Optional cwd filter or filters; when set, only threads whose session cwd /// exactly matches one of these paths are returned. #[ts(optional = nullable, type = "string | Array | null")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 77d4831edec2..4e681271d1ac 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -183,6 +183,9 @@ pub struct Thread { pub preview: String, /// Whether the thread is ephemeral and should not be materialized on disk. pub ephemeral: bool, + /// Whether the thread has been pinned by the user. + #[serde(default)] + pub is_pinned: bool, /// Persisted thread history contract selected when this thread was created. #[experimental("thread.historyMode")] #[serde(default)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 21becb302fbe..2fd6f365a6c1 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1308,6 +1308,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 model_providers: None, source_kinds: None, archived: None, + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 40d76de738e4..1ef2eafbdc4b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -141,13 +141,13 @@ Example with notification opt-out: - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` boundary is rejected. Experimental `beforeTurnId` instead copies history strictly before the referenced turn, including when that turn is in progress, and cannot be combined with `lastTurnId`. If both boundaries are null while the source thread is mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately, or `deferGoalContinuation: true` to carry the source thread's current goal into the fork and run an explicit turn before automatic continuation resumes. Deferred goal continuation is persisted until that turn starts and cannot be combined with `ephemeral: true`. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. -- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. +- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `isPinned`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. For loaded threads, experimental clients can use `canAcceptDirectInput` to determine whether `turn/start` and `turn/steer` are accepted; unloaded stored threads report `null` when that capability is unavailable. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination. - `thread/searchOccurrences` — experimental; find literal, case-insensitive matches in visible user messages and summary-selected final assistant messages within one paginated thread. -- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. +- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields and `isPinned`, and returns the refreshed `thread`. - `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; deprecated `multiAgentMode` is ignored, while Ultra reasoning effort enables proactive multi-agent behavior; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. @@ -388,6 +388,7 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). +- `isPinned` — when provided, return only threads whose persisted pin state matches the requested value; omit it to include both pinned and unpinned threads. - `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching. - `useStateDbOnly` — when `true`, return from the state DB without scanning JSONL rollouts to repair metadata. Omit or pass `false` to preserve the default scan-and-repair behavior. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index dce864366f60..4df558b2675c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2203,6 +2203,7 @@ mod tests { updated_at: created_at, recency_at: created_at, archived_at: None, + is_pinned: false, cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 812b42707913..607b71f8bbd7 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -19,6 +19,7 @@ struct ThreadListFilters { model_providers: Option>, source_kinds: Option>, archived: bool, + is_pinned: Option, cwd_filters: Option>, search_term: Option, use_state_db_only: bool, @@ -1654,35 +1655,47 @@ impl ThreadRequestProcessor { let ThreadMetadataUpdateParams { thread_id, git_info, + is_pinned, } = params; let thread_uuid = ThreadId::from_string(&thread_id) .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - let Some(ThreadMetadataGitInfoUpdateParams { - sha, - branch, - origin_url, - }) = git_info - else { - return Err(invalid_request("gitInfo must include at least one field")); - }; - - if sha.is_none() && branch.is_none() && origin_url.is_none() { - return Err(invalid_request("gitInfo must include at least one field")); + if git_info.is_none() && is_pinned.is_none() { + return Err(invalid_request( + "thread metadata update must include at least one field", + )); } - let git_sha = Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?; - let git_branch = Self::normalize_thread_metadata_git_field(branch, "gitInfo.branch")?; - let git_origin_url = - Self::normalize_thread_metadata_git_field(origin_url, "gitInfo.originUrl")?; + let git_info = git_info + .map( + |ThreadMetadataGitInfoUpdateParams { + sha, + branch, + origin_url, + }| { + if sha.is_none() && branch.is_none() && origin_url.is_none() { + return Err(invalid_request("gitInfo must include at least one field")); + } + + Ok(StoreGitInfoPatch { + sha: Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?, + branch: Self::normalize_thread_metadata_git_field( + branch, + "gitInfo.branch", + )?, + origin_url: Self::normalize_thread_metadata_git_field( + origin_url, + "gitInfo.originUrl", + )?, + }) + }, + ) + .transpose()?; let patch = StoreThreadMetadataPatch { - git_info: Some(StoreGitInfoPatch { - sha: git_sha, - branch: git_branch, - origin_url: git_origin_url, - }), + git_info, + is_pinned, ..Default::default() }; @@ -1981,6 +1994,7 @@ impl ThreadRequestProcessor { model_providers, source_kinds, archived, + is_pinned, cwd, use_state_db_only, search_term, @@ -2025,6 +2039,7 @@ impl ThreadRequestProcessor { model_providers, source_kinds, archived: archived.unwrap_or(false), + is_pinned, cwd_filters, search_term, use_state_db_only, @@ -4343,6 +4358,7 @@ impl ThreadRequestProcessor { model_providers, source_kinds, archived, + is_pinned, cwd_filters, search_term, use_state_db_only, @@ -4390,6 +4406,7 @@ impl ThreadRequestProcessor { model_providers: model_provider_filter.clone(), cwd_filters: cwd_filters.clone(), archived, + is_pinned, search_term: search_term.clone(), use_state_db_only, relation_filter, @@ -4972,6 +4989,7 @@ pub(crate) fn thread_from_stored_thread( parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()), preview: thread.preview, ephemeral: false, + is_pinned: thread.is_pinned, history_mode: thread.history_mode.into(), model_provider: if thread.model_provider.is_empty() { fallback_provider.to_string() @@ -5183,6 +5201,7 @@ fn build_thread_from_snapshot( parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()), preview: String::new(), ephemeral: config_snapshot.ephemeral, + is_pinned: false, history_mode: config_snapshot.history_mode.into(), model_provider: config_snapshot.model_provider_id.clone(), created_at: now, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 7c2c286f77ea..bf24ad4a68e1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -480,6 +480,7 @@ mod thread_processor_behavior_tests { updated_at: updated_at.with_timezone(&Utc), recency_at: updated_at.with_timezone(&Utc), archived_at: None, + is_pinned: false, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 9cff8bfb69e8..fa03d3483273 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -194,6 +194,7 @@ mod tests { parent_thread_id: None, preview: "preview".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "mock_provider".to_string(), created_at: 0, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index e62ca6876f7c..8376866aceea 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -307,6 +307,7 @@ pub(crate) fn summary_to_thread( parent_thread_id: None, preview, ephemeral: false, + is_pinned: false, history_mode: ThreadHistoryMode::Legacy, model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index e34af5279ebe..73ebb6723a81 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -412,6 +412,7 @@ source = {:?} model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1588,6 +1589,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1771,6 +1773,7 @@ required = true model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1854,6 +1857,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1934,6 +1938,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2060,6 +2065,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2174,6 +2180,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 27c99fa2adae..1ee3173bd6a0 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -177,6 +177,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist model_providers: Some(Vec::new()), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 3df18fa86587..2a7dba69c0d5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -78,6 +78,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result { model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 49e1f5558546..e17c44f56870 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -95,6 +95,7 @@ async fn list_threads_with_sort( model_providers: providers, source_kinds, archived, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -132,6 +133,7 @@ async fn list_threads_for_relation( model_providers, source_kinds, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -535,6 +537,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: Some(ThreadListCwdFilter::Many(vec![ first_target_cwd.to_string_lossy().into_owned(), second_target_cwd.to_string_lossy().into_owned(), @@ -644,6 +647,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: Some("needle".to_string()), @@ -844,6 +848,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -878,6 +883,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: Some(ThreadListCwdFilter::One( stale_cwd.to_string_lossy().into_owned(), )), @@ -905,6 +911,7 @@ sqlite = true model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: Some(ThreadListCwdFilter::One( stale_cwd.to_string_lossy().into_owned(), )), @@ -1088,6 +1095,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1112,6 +1120,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { model_providers: None, source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1901,6 +1910,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1940,6 +1950,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -2175,6 +2186,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 49fcb7ff55d5..b754e0eaa886 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -9,6 +9,8 @@ use codex_app_server_protocol::GitInfo; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateResponse; @@ -16,6 +18,7 @@ use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -37,6 +40,164 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +#[tokio::test] +async fn thread_metadata_update_pins_and_unpins_with_filtered_recency_pagination() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()).write(codex_home.path())?; + let state_db = init_state_db(codex_home.path()).await?; + + let mut thread_ids = Vec::new(); + for (filename_timestamp, timestamp, preview) in [ + ( + "2025-01-06T08-00-00", + "2025-01-06T08:00:00Z", + "Older pinned", + ), + ("2025-01-06T09-00-00", "2025-01-06T09:00:00Z", "Unpinned"), + ( + "2025-01-06T10-00-00", + "2025-01-06T10:00:00Z", + "Newer pinned", + ), + ] { + let thread_id = create_fake_rollout( + codex_home.path(), + filename_timestamp, + timestamp, + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + reconcile_rollout( + Some(&state_db), + rollout_path(codex_home.path(), filename_timestamp, &thread_id).as_path(), + "mock_provider", + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + thread_ids.push(thread_id); + } + let [older_pinned, initially_unpinned, newer_pinned] = thread_ids.as_slice() else { + unreachable!("three fake rollouts were created"); + }; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + for thread_id in [older_pinned, newer_pinned] { + let request_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: None, + is_pinned: Some(true), + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let wire_is_pinned = response + .result + .get("thread") + .and_then(|thread| thread.get("isPinned")) + .and_then(Value::as_bool); + let ThreadMetadataUpdateResponse { thread } = to_response(response)?; + assert_eq!(thread.id, *thread_id); + assert!(thread.is_pinned); + assert_eq!(wire_is_pinned, Some(true)); + } + + let list_params = ThreadListParams { + cursor: None, + limit: Some(1), + sort_key: Some(ThreadSortKey::RecencyAt), + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + is_pinned: Some(true), + cwd: None, + use_state_db_only: false, + search_term: None, + parent_thread_id: None, + ancestor_thread_id: None, + }; + let request_id = mcp.send_thread_list_request(list_params.clone()).await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let first_page: ThreadListResponse = to_response(response)?; + assert_eq!(first_page.data.len(), 1); + assert_eq!(first_page.data[0].id, *newer_pinned); + assert!(first_page.data[0].is_pinned); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: first_page.next_cursor, + ..list_params.clone() + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let second_page: ThreadListResponse = to_response(response)?; + assert_eq!(second_page.data.len(), 1); + assert_eq!(second_page.data[0].id, *older_pinned); + assert!(second_page.data[0].is_pinned); + + let request_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: newer_pinned.clone(), + git_info: None, + is_pinned: Some(false), + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread } = to_response(response)?; + assert!(!thread.is_pinned); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + limit: Some(10), + is_pinned: Some(false), + ..list_params + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let unpinned_page: ThreadListResponse = to_response(response)?; + assert_eq!( + unpinned_page + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + [newer_pinned.as_str(), initially_unpinned.as_str()] + ); + assert!(unpinned_page.data.iter().all(|thread| !thread.is_pinned)); + + Ok(()) +} + #[tokio::test] async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -65,6 +226,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/sidebar-pr".to_string())), @@ -163,6 +325,7 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id, + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: None, @@ -213,6 +376,7 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread.id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/ephemeral".to_string())), @@ -265,6 +429,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/stored-thread".to_string())), @@ -349,6 +514,7 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/loaded-thread".to_string())), @@ -416,6 +582,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/archived-thread".to_string())), @@ -476,6 +643,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: Some(None), branch: Some(None), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index a15d9d94ff10..645de1f8bc94 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -254,6 +254,7 @@ async fn paginated_stored_thread_routes_projected_turns_and_rejects_legacy_histo model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -885,6 +886,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> model_providers: Some(Vec::new()), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: false, search_term: None, @@ -1263,6 +1265,7 @@ async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, + is_pinned: None, cwd: None, use_state_db_only: true, search_term: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 21018e9b576f..f83096f9d434 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2493,6 +2493,7 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res let update_id = mcp .send_thread_metadata_update_request(ThreadMetadataUpdateParams { thread_id: thread_id.clone(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some("feature/pr-branch".to_string())), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 433fe88afd6c..55ba7a21ee31 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -13,6 +13,8 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -95,6 +97,18 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ) .await??; + let pin_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + git_info: None, + is_pinned: Some(true), + }) + .await?; + let ThreadMetadataUpdateResponse { + thread: pinned_thread, + } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(pin_id)).await??; + assert!(pinned_thread.is_pinned); + let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) .await? @@ -152,6 +166,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ) .await??; assert_eq!(unarchived_notification.thread_id, thread.id); + assert!(unarchived_thread.is_pinned); assert!( unarchived_thread.updated_at > old_timestamp, "expected updated_at to be bumped on unarchive" @@ -164,6 +179,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result .and_then(Value::as_object) .expect("thread/unarchive result.thread must be an object"); assert_eq!(unarchived_thread.name, None); + assert_eq!(thread_json.get("isPinned"), Some(&Value::Bool(true))); assert_eq!( thread_json.get("name"), Some(&Value::Null), diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index ec881bdcc963..2281d75da8f2 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -141,6 +141,7 @@ async fn load_recent_threads(sess: &Session) -> Vec { cwd_filters: None, relation_filter: None, archived: false, + is_pinned: None, search_term: None, use_state_db_only: false, }) diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 23f0b0c03a49..e2dfbf06bf52 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -52,6 +52,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre .single() .expect("valid timestamp"), archived_at: None, + is_pinned: false, cwd: PathBuf::from(cwd), cli_version: "test".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5e0a9ca9ea19..a13c83124bd6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1479,6 +1479,7 @@ async fn resolve_resume_thread_id( model_providers: model_providers.clone(), source_kinds: Some(all_thread_source_kinds()), archived: Some(false), + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, @@ -1546,6 +1547,7 @@ async fn resolve_resume_thread_id( model_providers: model_providers.clone(), source_kinds: Some(all_thread_source_kinds()), archived: Some(false), + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index f624752d0d96..624128fe92a1 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -335,6 +335,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { parent_thread_id: None, preview: String::new(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, @@ -802,6 +803,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { parent_thread_id: None, preview: String::new(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index 137ee6642ecf..dc5f9878a7fb 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -55,6 +55,8 @@ pub struct ThreadItem { pub first_user_message: Option, /// Best available user-facing preview for discovery and list display. pub preview: Option, + /// Whether the thread is pinned in SQLite-owned metadata. + pub is_pinned: bool, /// Working directory from session metadata. pub cwd: Option, /// Git branch from session metadata. @@ -830,6 +832,7 @@ async fn build_thread_item( thread_id, first_user_message, preview, + is_pinned: false, cwd, git_branch, git_sha, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index dca1c9a601b0..7b6d783fc96c 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -458,6 +458,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, + /*is_pinned*/ None, search_term, ) .await @@ -567,6 +568,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, + /*is_pinned*/ None, search_term, ) .await; @@ -596,6 +598,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, + /*is_pinned*/ None, search_term, ) .await @@ -636,6 +639,7 @@ impl RolloutRecorder { cwd_filters, /*relation_filter*/ None, archived, + /*is_pinned*/ None, search_term, ) .await @@ -714,6 +718,7 @@ impl RolloutRecorder { cwd_filter.as_ref().map(std::slice::from_ref), /*relation_filter*/ None, /*archived*/ false, + /*is_pinned*/ None, /*search_term*/ None, ) .await @@ -1180,6 +1185,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt thread_id: _state_thread_id, first_user_message, preview, + is_pinned, cwd, git_branch, git_sha, @@ -1202,6 +1208,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.preview.is_none() { item.preview = preview; } + item.is_pinned = is_pinned; if item.cwd.is_none() { item.cwd = cwd; } @@ -1928,6 +1935,7 @@ fn thread_item_from_state_metadata( thread_id: Some(item.id), first_user_message: item.first_user_message, preview: item.preview, + is_pinned: item.is_pinned, cwd: Some(item.cwd), git_branch: item.git_branch, git_sha: item.git_sha, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 0eba013d5b99..248e9c218238 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1332,6 +1332,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi thread_id: Some(filesystem_thread_id), first_user_message: Some("filesystem message".to_string()), preview: Some("filesystem preview".to_string()), + is_pinned: false, cwd: None, git_branch: Some("filesystem-branch".to_string()), git_sha: Some("filesystem-sha".to_string()), @@ -1352,6 +1353,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi thread_id: Some(state_thread_id), first_user_message: Some("state message".to_string()), preview: Some("state preview".to_string()), + is_pinned: true, cwd: Some(PathBuf::from("/tmp/state-cwd")), git_branch: Some("state-branch".to_string()), git_sha: Some("state-sha".to_string()), @@ -1372,6 +1374,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi assert_eq!(item.path, filesystem_path); assert_eq!(item.thread_id, Some(filesystem_thread_id)); + assert!(item.is_pinned); assert_eq!( item.first_user_message.as_deref(), Some("filesystem message") diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 1b4db3f73eec..916c75a32557 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -372,6 +372,7 @@ pub async fn list_threads_db( cwd_filters: Option<&[PathBuf]>, relation_filter: Option, archived: bool, + is_pinned: Option, search_term: Option<&str>, ) -> Option { let ctx = context?; @@ -401,6 +402,7 @@ pub async fn list_threads_db( }); let filters = codex_state::ThreadFilterOptions { archived_only: archived, + is_pinned, allowed_sources: allowed_sources.as_slice(), model_providers: model_providers.as_deref(), cwd_filters: normalized_cwd_filters.as_deref(), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 0dbbbc8a90b2..e504b716428c 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -640,6 +640,7 @@ async fn test_list_conversations_latest_first() { thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -660,6 +661,7 @@ async fn test_list_conversations_latest_first() { thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -680,6 +682,7 @@ async fn test_list_conversations_latest_first() { thread_id: Some(thread_id_from_uuid(u1)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -793,6 +796,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u5)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -813,6 +817,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u4)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -869,6 +874,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -889,6 +895,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -937,6 +944,7 @@ async fn test_pagination_cursor() { thread_id: Some(thread_id_from_uuid(u1)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -1110,6 +1118,7 @@ async fn test_get_thread_contents() { thread_id: Some(thread_id_from_uuid(uuid)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -1477,6 +1486,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { thread_id: Some(thread_id_from_uuid(u3)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, @@ -1497,6 +1507,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { thread_id: Some(thread_id_from_uuid(u2)), first_user_message: Some("Hello from user".to_string()), preview: Some("Hello from user".to_string()), + is_pinned: false, cwd: Some(Path::new(".").to_path_buf()), git_branch: None, git_sha: None, diff --git a/codex-rs/state/migrations/0043_threads_is_pinned.sql b/codex-rs/state/migrations/0043_threads_is_pinned.sql new file mode 100644 index 000000000000..7902696e041b --- /dev/null +++ b/codex-rs/state/migrations/0043_threads_is_pinned.sql @@ -0,0 +1,5 @@ +ALTER TABLE threads ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_threads_pinned_recency_at_ms + ON threads(archived, recency_at_ms DESC, id DESC) + WHERE is_pinned = 1 AND preview <> ''; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 06dfff5a738a..10ce38f6c3a1 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -694,6 +694,7 @@ mod tests { tokens_used: 1, first_user_message: None, archived_at: None, + is_pinned: false, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs index 38878f4dcd16..3cd5c653f685 100644 --- a/codex-rs/state/src/migrations_tests.rs +++ b/codex-rs/state/src/migrations_tests.rs @@ -26,6 +26,79 @@ fn migrator_through(version: i64) -> Migrator { } } +#[tokio::test] +async fn pinned_threads_migration_defaults_existing_and_legacy_rows_to_unpinned() { + let sqlite_home = crate::runtime::test_support::unique_temp_dir(); + tokio::fs::create_dir_all(&sqlite_home) + .await + .expect("sqlite home should be created"); + let _cleanup = scopeguard::guard(sqlite_home.clone(), |sqlite_home| { + let _ = std::fs::remove_dir_all(sqlite_home); + }); + let sqlite = crate::SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); + let pool = sqlite + .open_read_write_pool(&state_db_path(&sqlite_home)) + .await + .expect("sqlite database should open"); + migrator_through(/*version*/ 42) + .run(&pool) + .await + .expect("pre-pin migrations should apply"); + + for thread_id in [ + "00000000-0000-0000-0000-000000000043", + "00000000-0000-0000-0000-000000000044", + ] { + if thread_id.ends_with("44") { + STATE_MIGRATOR + .run(&pool) + .await + .expect("pin migration should apply"); + } + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + created_at_ms, + updated_at_ms, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(thread_id) + .bind("/tmp/legacy.jsonl") + .bind(1_700_000_000_i64) + .bind(1_700_000_000_i64) + .bind(1_700_000_000_000_i64) + .bind(1_700_000_000_000_i64) + .bind("cli") + .bind("openai") + .bind("/tmp") + .bind("") + .bind("read-only") + .bind("on-request") + .execute(&pool) + .await + .expect("legacy thread insert should succeed"); + } + + let pinned_values = sqlx::query_scalar::<_, bool>("SELECT is_pinned FROM threads ORDER BY id") + .fetch_all(&pool) + .await + .expect("pin states should load"); + assert_eq!(pinned_values, vec![false, false]); + + pool.close().await; +} + #[tokio::test] async fn agent_job_tables_are_dropped_when_upgrading() { let sqlite_home = crate::runtime::test_support::unique_temp_dir(); diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 035cca0202a4..e1ae5c91887c 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -124,6 +124,8 @@ pub struct ThreadMetadata { pub first_user_message: Option, /// The archive timestamp, if the thread is archived. pub archived_at: Option>, + /// Whether the thread was explicitly pinned by the user. + pub is_pinned: bool, /// The git commit SHA, if known. pub git_sha: Option, /// The git branch name, if known. @@ -254,6 +256,7 @@ impl ThreadMetadataBuilder { tokens_used: 0, first_user_message: None, archived_at: self.archived_at.map(canonicalize_datetime), + is_pinned: false, git_sha: self.git_sha.clone(), git_branch: self.git_branch.clone(), git_origin_url: self.git_origin_url.clone(), @@ -368,6 +371,9 @@ impl ThreadMetadata { if self.archived_at != other.archived_at { diffs.push("archived_at"); } + if self.is_pinned != other.is_pinned { + diffs.push("is_pinned"); + } if self.git_sha != other.git_sha { diffs.push("git_sha"); } @@ -411,6 +417,7 @@ pub(crate) struct ThreadRow { tokens_used: i64, first_user_message: String, archived_at: Option, + is_pinned: bool, git_sha: Option, git_branch: Option, git_origin_url: Option, @@ -443,6 +450,7 @@ impl ThreadRow { tokens_used: row.try_get("tokens_used")?, first_user_message: row.try_get("first_user_message")?, archived_at: row.try_get("archived_at")?, + is_pinned: row.try_get("is_pinned")?, git_sha: row.try_get("git_sha")?, git_branch: row.try_get("git_branch")?, git_origin_url: row.try_get("git_origin_url")?, @@ -479,6 +487,7 @@ impl TryFrom for ThreadMetadata { tokens_used, first_user_message, archived_at, + is_pinned, git_sha, git_branch, git_origin_url, @@ -514,6 +523,7 @@ impl TryFrom for ThreadMetadata { tokens_used, first_user_message: (!first_user_message.is_empty()).then_some(first_user_message), archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?, + is_pinned, git_sha, git_branch, git_origin_url, @@ -612,6 +622,7 @@ mod tests { tokens_used: 1, first_user_message: String::new(), archived_at: None, + is_pinned: false, git_sha: None, git_branch: None, git_origin_url: None, @@ -645,6 +656,7 @@ mod tests { tokens_used: 1, first_user_message: None, archived_at: None, + is_pinned: false, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 33103584c60f..39fb560ab186 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -195,6 +195,7 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, + threads.is_pinned, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -208,6 +209,7 @@ FROM threads allowed_sources, model_providers: None, cwd_filters: None, + is_pinned: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -569,6 +571,7 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, + threads.is_pinned, threads.git_sha, threads.git_branch, threads.git_origin_url diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index f4b23bfaa5cd..e18c68f82547 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -69,6 +69,7 @@ pub(super) fn test_thread_metadata( tokens_used: 0, first_user_message: Some("hello".to_string()), archived_at: None, + is_pinned: false, git_sha: None, git_branch: None, git_origin_url: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 2cc7b10ebbd0..fbf0655ffbd2 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -33,6 +33,7 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, + threads.is_pinned, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -372,6 +373,7 @@ ON CONFLICT(child_thread_id) DO NOTHING allowed_sources, model_providers, cwd_filters: None, + is_pinned: None, anchor: None, sort_key: crate::SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -496,6 +498,7 @@ ON CONFLICT(child_thread_id) DO NOTHING allowed_sources, model_providers, cwd_filters: None, + is_pinned: None, anchor, sort_key, sort_direction: SortDirection::Desc, @@ -565,11 +568,12 @@ INSERT INTO threads ( first_user_message, archived, archived_at, + is_pinned, git_sha, git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -611,6 +615,7 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.first_user_message.as_deref().unwrap_or_default()) .bind(metadata.archived_at.is_some()) .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) + .bind(metadata.is_pinned) .bind(metadata.git_sha.as_deref()) .bind(metadata.git_branch.as_deref()) .bind(metadata.git_origin_url.as_deref()) @@ -661,6 +666,20 @@ ON CONFLICT(id) DO NOTHING Ok(result.rows_affected() > 0) } + /// Update the SQLite-owned pinned state without changing other thread metadata. + pub async fn update_thread_pin( + &self, + thread_id: ThreadId, + is_pinned: bool, + ) -> anyhow::Result { + let result = sqlx::query("UPDATE threads SET is_pinned = ? WHERE id = ?") + .bind(is_pinned) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn touch_thread_updated_at( &self, thread_id: ThreadId, @@ -834,11 +853,12 @@ INSERT INTO threads ( first_user_message, archived, archived_at, + is_pinned, git_sha, git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -909,6 +929,7 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.first_user_message.as_deref().unwrap_or_default()) .bind(metadata.archived_at.is_some()) .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) + .bind(metadata.is_pinned) .bind(metadata.git_sha.as_deref()) .bind(metadata.git_branch.as_deref()) .bind(metadata.git_origin_url.as_deref()) @@ -1190,6 +1211,7 @@ SELECT threads.tokens_used, threads.first_user_message, threads.archived_at, + threads.is_pinned, threads.git_sha, threads.git_branch, threads.git_origin_url @@ -1222,6 +1244,7 @@ pub struct ThreadFilterOptions<'a> { pub allowed_sources: &'a [String], pub model_providers: Option<&'a [String]>, pub cwd_filters: Option<&'a [PathBuf]>, + pub is_pinned: Option, pub anchor: Option<&'a crate::Anchor>, pub sort_key: SortKey, pub sort_direction: SortDirection, @@ -1238,6 +1261,7 @@ pub(super) fn push_thread_filters<'a>( allowed_sources, model_providers, cwd_filters, + is_pinned, anchor, sort_key, sort_direction, @@ -1250,6 +1274,10 @@ pub(super) fn push_thread_filters<'a>( builder.push(" AND threads.archived = 0"); } builder.push(" AND threads.preview <> ''"); + if let Some(is_pinned) = is_pinned { + builder.push(" AND threads.is_pinned = "); + builder.push_bind(is_pinned); + } if !allowed_sources.is_empty() { builder.push(" AND threads.source IN ("); let mut separated = builder.separated(", "); @@ -1455,6 +1483,173 @@ mod tests { assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated); } + #[tokio::test] + async fn thread_pin_updates_round_trip_and_survive_rollout_reconciliation() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = ThreadId::new(); + let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + assert!( + !runtime + .get_thread(thread_id) + .await + .unwrap() + .unwrap() + .is_pinned + ); + + assert!( + runtime + .update_thread_pin(thread_id, /*is_pinned*/ true) + .await + .unwrap() + ); + assert!( + runtime + .get_thread(thread_id) + .await + .unwrap() + .unwrap() + .is_pinned + ); + + runtime + .upsert_thread(&metadata) + .await + .expect("stale rollout metadata should reconcile"); + assert!( + runtime + .get_thread(thread_id) + .await + .unwrap() + .unwrap() + .is_pinned + ); + + assert!( + runtime + .update_thread_pin(thread_id, /*is_pinned*/ false) + .await + .unwrap() + ); + assert!( + !runtime + .get_thread(thread_id) + .await + .unwrap() + .unwrap() + .is_pinned + ); + assert!( + !runtime + .update_thread_pin(ThreadId::new(), /*is_pinned*/ true) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn list_threads_filters_pins_before_recency_pagination_and_uses_index() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let oldest_pinned = ThreadId::from_string("00000000-0000-0000-0000-000000000041").unwrap(); + let newest_unpinned = + ThreadId::from_string("00000000-0000-0000-0000-000000000042").unwrap(); + let newest_pinned = ThreadId::from_string("00000000-0000-0000-0000-000000000043").unwrap(); + let oldest_unpinned = + ThreadId::from_string("00000000-0000-0000-0000-000000000044").unwrap(); + + for (thread_id, recency_at, is_pinned) in [ + (oldest_pinned, 1_700_000_001, true), + (newest_unpinned, 1_700_000_003, false), + (newest_pinned, 1_700_000_002, true), + (oldest_unpinned, 1_700_000_000, false), + ] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.recency_at = DateTime::::from_timestamp(recency_at, 0).unwrap(); + metadata.is_pinned = is_pinned; + runtime.upsert_thread(&metadata).await.unwrap(); + } + + let filters = |anchor, is_pinned| ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: None, + is_pinned: Some(is_pinned), + anchor, + sort_key: SortKey::RecencyAt, + sort_direction: SortDirection::Desc, + search_term: None, + }; + let first_page = runtime + .list_threads(/*page_size*/ 1, filters(None, true)) + .await + .unwrap(); + assert_eq!(first_page.items.len(), 1); + assert_eq!(first_page.items[0].id, newest_pinned); + assert!(first_page.items[0].is_pinned); + let second_page = runtime + .list_threads( + /*page_size*/ 1, + filters(first_page.next_anchor.as_ref(), true), + ) + .await + .unwrap(); + assert_eq!(second_page.items.len(), 1); + assert_eq!(second_page.items[0].id, oldest_pinned); + assert_eq!(second_page.next_anchor, None); + + let unpinned_page = runtime + .list_threads(/*page_size*/ 10, filters(None, false)) + .await + .unwrap(); + assert_eq!( + unpinned_page + .items + .iter() + .map(|thread| thread.id) + .collect::>(), + vec![newest_unpinned, oldest_unpinned] + ); + + let mut builder = QueryBuilder::::new("EXPLAIN QUERY PLAN "); + push_list_threads_query( + &mut builder, + filters(None, true), + /*relation_filter*/ None, + /*limit*/ 2, + ); + let plan_details = builder + .build() + .fetch_all(runtime.pool.as_ref()) + .await + .unwrap() + .into_iter() + .map(|row| row.get::("detail")) + .collect::>(); + assert!( + plan_details + .iter() + .any(|detail| detail.contains("idx_threads_pinned_recency_at_ms")), + "pinned listing did not use its selective recency index: {plan_details:?}" + ); + assert!( + !plan_details + .iter() + .any(|detail| detail.contains("TEMP B-TREE")), + "pinned listing unexpectedly sorted outside its index: {plan_details:?}" + ); + } + #[tokio::test] async fn delete_thread_cleans_associated_state() -> Result<()> { let codex_home = unique_temp_dir(); @@ -1628,6 +1823,7 @@ mod tests { allowed_sources: &[], model_providers: Some(&model_providers), cwd_filters: None, + is_pinned: None, anchor: Some(&anchor), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Asc, @@ -1656,6 +1852,7 @@ mod tests { allowed_sources: &[], model_providers: Some(&model_providers), cwd_filters: None, + is_pinned: None, anchor: page.next_anchor.as_ref(), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Asc, @@ -1709,6 +1906,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: Some(cwd_filters.as_slice()), + is_pinned: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -1741,6 +1939,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: Some(cwd_filters.as_slice()), + is_pinned: None, anchor: first_page.next_anchor.as_ref(), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -1766,6 +1965,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: Some(&[]), + is_pinned: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -1830,6 +2030,7 @@ mod tests { allowed_sources: &[], model_providers: Some(&model_providers), cwd_filters, + is_pinned: None, anchor, sort_key, sort_direction: SortDirection::Desc, @@ -1923,6 +2124,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, + is_pinned: None, anchor: None, sort_key: SortKey::CreatedAt, sort_direction: SortDirection::Desc, @@ -1951,6 +2153,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, + is_pinned: None, anchor, sort_key: SortKey::CreatedAt, sort_direction: SortDirection::Desc, @@ -2595,6 +2798,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, + is_pinned: None, anchor: None, sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, @@ -2627,6 +2831,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, + is_pinned: None, anchor: first_page.next_anchor.as_ref(), sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, @@ -2659,6 +2864,7 @@ mod tests { allowed_sources: &[], model_providers: None, cwd_filters: None, + is_pinned: None, anchor: second_page.next_anchor.as_ref(), sort_key: SortKey::RecencyAt, sort_direction: SortDirection::Desc, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 6ae8e7d8ca3c..83b9e6a194f1 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -144,6 +144,18 @@ mod tests { .expect("create thread"); } + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id: grandchild_thread_id, + patch: ThreadMetadataPatch { + is_pinned: Some(true), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("pin grandchild thread"); + let page = ThreadStore::list_threads( &store, ListThreadsParams { @@ -154,6 +166,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: None, relation_filter: Some(ThreadRelationFilter::DirectChildrenOf(parent_thread_id)), @@ -181,6 +194,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: None, relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)), @@ -197,6 +211,34 @@ mod tests { .collect::>(), HashSet::from([child_thread_id, grandchild_thread_id]) ); + + let page = ThreadStore::list_threads( + &store, + ListThreadsParams { + page_size: 10, + cursor: None, + sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, + allowed_sources: Vec::new(), + model_providers: None, + cwd_filters: None, + is_pinned: Some(true), + archived: false, + search_term: None, + relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)), + use_state_db_only: false, + }, + ) + .await + .expect("list pinned descendant threads"); + + assert_eq!( + page.items + .into_iter() + .map(|item| item.thread_id) + .collect::>(), + vec![grandchild_thread_id] + ); } #[tokio::test] @@ -725,6 +767,9 @@ impl ThreadStore for InMemoryThreadStore { } None => {} } + if let Some(is_pinned) = params.is_pinned { + page.items.retain(|thread| thread.is_pinned == is_pinned); + } Ok(page) }) } @@ -808,6 +853,9 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.advance_recency_at.or(metadata.updated_at)) .unwrap_or_else(Utc::now), archived_at: None, + is_pinned: metadata + .and_then(|metadata| metadata.is_pinned) + .unwrap_or(false), cwd: metadata .and_then(|metadata| metadata.cwd.clone()) .unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index ac2a6f4b397a..35ed6100cf8c 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -108,6 +108,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: true, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index b8ac90507d58..b0727931308d 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -144,6 +144,7 @@ pub(super) fn stored_thread_from_rollout_item( updated_at, recency_at, archived_at, + is_pinned: item.is_pinned, cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), source, diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 8d15de4e3193..3d416039b786 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -97,15 +97,17 @@ pub(super) async fn list_rollout_threads( sort_key: codex_rollout::ThreadSortKey, sort_direction: codex_rollout::SortDirection, ) -> ThreadStoreResult { - if let Some(relation_filter) = params.relation_filter { - let relation_filter = match relation_filter { - ThreadRelationFilter::DirectChildrenOf(parent_thread_id) => { - codex_state::ThreadRelationFilter::DirectChildrenOf(parent_thread_id) - } - ThreadRelationFilter::DescendantsOf(ancestor_thread_id) => { - codex_state::ThreadRelationFilter::DescendantsOf(ancestor_thread_id) - } - }; + if params.relation_filter.is_some() || params.is_pinned.is_some() { + let relation_filter = params + .relation_filter + .map(|relation_filter| match relation_filter { + ThreadRelationFilter::DirectChildrenOf(parent_thread_id) => { + codex_state::ThreadRelationFilter::DirectChildrenOf(parent_thread_id) + } + ThreadRelationFilter::DescendantsOf(ancestor_thread_id) => { + codex_state::ThreadRelationFilter::DescendantsOf(ancestor_thread_id) + } + }); let page = codex_rollout::state_db::list_threads_db( state_db.as_deref(), config.codex_home.as_path(), @@ -116,13 +118,14 @@ pub(super) async fn list_rollout_threads( params.allowed_sources.as_slice(), params.model_providers.as_deref(), params.cwd_filters.as_deref(), - Some(relation_filter), + relation_filter, params.archived, + params.is_pinned, params.search_term.as_deref(), ) .await .ok_or_else(|| ThreadStoreError::Internal { - message: "state DB unavailable for relationship-filtered thread listing".to_string(), + message: "state DB unavailable for filtered thread listing".to_string(), })?; return Ok(page.into()); } @@ -236,6 +239,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: None, relation_filter: None, @@ -296,6 +300,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: Some("needle".to_string()), relation_filter: None, @@ -368,6 +373,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: Some("canonical".to_string()), relation_filter: None, @@ -404,6 +410,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: None, relation_filter: None, @@ -420,6 +427,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: true, search_term: None, relation_filter: None, @@ -472,6 +480,7 @@ mod tests { allowed_sources: vec![SessionSource::Cli], model_providers: Some(vec!["test-provider".to_string()]), cwd_filters: None, + is_pinned: None, archived: false, search_term: None, relation_filter: None, @@ -509,6 +518,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: false, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 6a6ed7dd6da2..568ddd93d285 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -59,6 +59,7 @@ pub(super) async fn read_thread( && !rollout_thread.preview.is_empty() { rollout_thread.recency_at = thread.recency_at; + rollout_thread.is_pinned = thread.is_pinned; if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -125,6 +126,7 @@ pub(super) async fn read_thread_by_rollout_path( thread.name = sqlite_thread_name(&metadata); } thread.recency_at = metadata.recency_at; + thread.is_pinned = metadata.is_pinned; thread.git_info = if thread.history_mode == ThreadHistoryMode::Paginated { // A paginated rollout only has the initial Git tuple. Do not turn an explicit SQLite // clear back into the stale rollout value while reading by path. @@ -384,6 +386,7 @@ async fn stored_thread_from_sqlite_metadata( updated_at: metadata.updated_at, recency_at: metadata.recency_at, archived_at: metadata.archived_at, + is_pinned: metadata.is_pinned, cwd: metadata.cwd, cli_version: metadata.cli_version, source: parse_session_source(&metadata.source), @@ -479,6 +482,7 @@ fn stored_thread_from_meta_line( updated_at, recency_at: updated_at, archived_at: archived.then_some(updated_at), + is_pinned: false, cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, source: meta_line.meta.source, diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index f6f2d77db8a7..6c2769bc4dad 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -93,6 +93,7 @@ pub(super) async fn search_threads( allowed_sources: params.allowed_sources.clone(), model_providers: None, cwd_filters: None, + is_pinned: None, archived: params.archived, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ee25b4342465..945c06b65ae2 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -1,13 +1,12 @@ use codex_rollout::find_archived_thread_path_by_id_str; -use codex_rollout::read_thread_item_from_rollout; use codex_rollout::rollout_date_parts; use super::LocalThreadStore; use super::helpers::matching_rollout_file_name; use super::helpers::scoped_rollout_path; -use super::helpers::stored_thread_from_rollout_item; use super::helpers::touch_modified_time; use crate::ArchiveThreadParams; +use crate::ReadThreadParams; use crate::StoredThread; use crate::ThreadStoreError; use crate::ThreadStoreResult; @@ -79,25 +78,15 @@ pub(super) async fn unarchive_thread( .await; } - let item = read_thread_item_from_rollout(restored_path.clone()) - .await - .ok_or_else(|| ThreadStoreError::Internal { - message: format!( - "failed to read unarchived thread {}", - restored_path.display() - ), - })?; - stored_thread_from_rollout_item( - item, - /*archived*/ false, - store.config.default_model_provider_id.as_str(), + super::read_thread::read_thread( + store, + ReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }, ) - .ok_or_else(|| ThreadStoreError::Internal { - message: format!( - "failed to read unarchived thread id from {}", - restored_path.display() - ), - }) + .await } #[cfg(test)] @@ -175,15 +164,17 @@ mod tests { builder.cli_version = Some("test_version".to_string()); let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.archived_at = Some(metadata.updated_at); + metadata.is_pinned = true; runtime .upsert_thread(&metadata) .await .expect("state db upsert should succeed"); - store + let unarchived = store .unarchive_thread(ArchiveThreadParams { thread_id }) .await .expect("unarchive thread"); + assert!(unarchived.is_pinned); let restored_path = home .path() @@ -197,5 +188,6 @@ mod tests { assert_eq!(updated.rollout_path, restored_path); assert_eq!(updated.archived_at, None); assert_eq!(updated.recency_at, metadata.recency_at); + assert!(updated.is_pinned); } } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index ccad7983ce4f..37082bdde25b 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -264,7 +264,7 @@ async fn apply_metadata_update( let sqlite_write_result: ThreadStoreResult<()> = if let Some(state_db) = state_db.as_ref() { let patch = patch.clone(); async { - let existing = + let mut existing = state_db .get_thread(thread_id) .await @@ -277,6 +277,30 @@ async fn apply_metadata_update( rollout_path_archived = resolved.archived; rollout_path = Some(resolved.path); } + if existing.is_none() + && patch.is_pinned.is_some() + && let Some(path) = rollout_path.as_deref() + && let Some(existing_rollout_path) = + codex_rollout::existing_rollout_path(path).await + { + codex_rollout::state_db::reconcile_rollout( + Some(state_db.as_ref()), + existing_rollout_path.as_path(), + store.config.default_model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ Some(rollout_path_archived), + /*new_thread_memory_mode*/ None, + ) + .await; + existing = state_db.get_thread(thread_id).await.map_err(|err| { + ThreadStoreError::Internal { + message: format!( + "failed to read reconciled thread metadata for {thread_id}: {err}" + ), + } + })?; + } let mut metadata = match existing.clone() { Some(metadata) => metadata, None => { @@ -365,6 +389,9 @@ async fn apply_metadata_update( if let Some(first_user_message) = patch.first_user_message { metadata.first_user_message = Some(first_user_message); } + if let Some(is_pinned) = patch.is_pinned { + metadata.is_pinned = is_pinned; + } if let Some(git_info) = patch.git_info { let existing_git_info = git_info_from_parts( metadata.git_sha.clone(), @@ -382,6 +409,23 @@ async fn apply_metadata_update( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to update thread metadata for {thread_id}: {err}"), })?; + if let Some(is_pinned) = patch.is_pinned { + let updated = state_db + .update_thread_pin(thread_id, is_pinned) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to update pin state for thread {thread_id}: {err}" + ), + })?; + if !updated { + return Err(ThreadStoreError::Internal { + message: format!( + "thread metadata unavailable before pin update: {thread_id}" + ), + }); + } + } if let Some(name) = patch.name.as_ref() { let history_mode = history_mode.ok_or_else(|| ThreadStoreError::Internal { message: format!( @@ -546,8 +590,9 @@ fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool { // transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that // failure isolation so a corrupted optional state DB does not make JSONL transcript durability // look broken. Explicit git-only updates still require SQLite because partial git patches need - // the existing SQLite value to preserve unspecified fields. - patch.git_info.is_some() && !has_observed_metadata_facts(patch) + // the existing SQLite value to preserve unspecified fields. User-selected pin state is + // SQLite-only, so losing its write must also fail the explicit metadata update. + patch.is_pinned.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch)) } fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool { @@ -845,6 +890,110 @@ mod tests { assert_eq!(latest_name.as_deref(), Some("A sharper name")); } + #[tokio::test] + async fn pin_only_metadata_updates_persist_in_sqlite_without_changing_the_rollout() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let uuid = Uuid::from_u128(320); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let rollout_path = + write_session_file(home.path(), "2025-01-03T14-20-00", uuid).expect("session file"); + let original_rollout = std::fs::read_to_string(&rollout_path).expect("read rollout"); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + + let pinned = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + is_pinned: Some(true), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("pin thread"); + + assert!(pinned.is_pinned); + let pinned_metadata = runtime + .get_thread(thread_id) + .await + .expect("read pinned metadata") + .expect("pinned metadata"); + assert!(pinned_metadata.is_pinned); + assert_eq!(pinned_metadata.preview.as_deref(), Some("Hello from user")); + assert_eq!(pinned_metadata.source, "cli"); + let pinned_page = store + .list_threads(ListThreadsParams { + page_size: 10, + cursor: None, + sort_key: ThreadSortKey::RecencyAt, + sort_direction: SortDirection::Desc, + allowed_sources: Vec::new(), + model_providers: None, + cwd_filters: None, + is_pinned: Some(true), + archived: false, + search_term: None, + relation_filter: None, + use_state_db_only: true, + }) + .await + .expect("list pinned thread"); + assert_eq!( + pinned_page + .items + .iter() + .map(|thread| thread.thread_id) + .collect::>(), + vec![thread_id] + ); + let read_by_path = store + .read_thread_by_rollout_path( + rollout_path.clone(), + /*include_archived*/ false, + /*include_history*/ false, + ) + .await + .expect("read pinned thread by rollout path"); + assert!(read_by_path.is_pinned); + assert_eq!( + std::fs::read_to_string(&rollout_path).expect("read rollout"), + original_rollout + ); + + let unpinned = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + is_pinned: Some(false), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("unpin thread"); + + assert!(!unpinned.is_pinned); + assert!( + !runtime + .get_thread(thread_id) + .await + .expect("read unpinned metadata") + .expect("unpinned metadata") + .is_pinned + ); + assert_eq!( + std::fs::read_to_string(&rollout_path).expect("read rollout"), + original_rollout + ); + } + #[tokio::test] async fn paginated_name_updates_use_sqlite_without_rollout_writes() { let home = TempDir::new().expect("temp dir"); @@ -1647,6 +1796,14 @@ mod tests { })); } + #[test] + fn sqlite_failures_block_for_explicit_pin_updates() { + assert!(sqlite_write_failure_should_block(&ThreadMetadataPatch { + is_pinned: Some(false), + ..Default::default() + })); + } + #[tokio::test] async fn metadata_patch_applies_title_over_existing_name() { let home = TempDir::new().expect("temp dir"); @@ -1920,6 +2077,7 @@ mod tests { allowed_sources: Vec::new(), model_providers: Some(Vec::new()), cwd_filters: Some(vec![workspace]), + is_pinned: None, archived: false, search_term: None, relation_filter: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 87bcbf755b63..b81f4cd4b8bc 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -247,6 +247,8 @@ pub struct ListThreadsParams { /// Optional cwd filters. `None` means all working directories, while an empty vector matches no /// threads. pub cwd_filters: Option>, + /// Optional persisted pin-state filter. + pub is_pinned: Option, /// Whether archived threads should be listed instead of active threads. pub archived: bool, /// Optional substring/full-text search term for thread title/preview. @@ -494,6 +496,8 @@ pub struct StoredThread { pub recency_at: DateTime, /// Thread archive timestamp, if archived. pub archived_at: Option>, + /// Whether this thread has been pinned by the user. + pub is_pinned: bool, /// Working directory captured for the thread. pub cwd: PathBuf, /// CLI version captured for the thread. @@ -650,6 +654,8 @@ pub struct ThreadMetadataPatch { pub token_usage: Option, /// First user message observed for this thread. pub first_user_message: Option, + /// Replacement user-selected thread pin state. + pub is_pinned: Option, /// Git metadata patch. pub git_info: Option, /// Thread memory behavior. @@ -726,6 +732,9 @@ impl ThreadMetadataPatch { if next.first_user_message.is_some() { self.first_user_message = next.first_user_message; } + if next.is_pinned.is_some() { + self.is_pinned = next.is_pinned; + } if let Some(git_info) = next.git_info { self.git_info .get_or_insert_with(GitInfoPatch::default) @@ -758,6 +767,7 @@ impl ThreadMetadataPatch { && self.permission_profile.is_none() && self.token_usage.is_none() && self.first_user_message.is_none() + && self.is_pinned.is_none() && self.git_info.is_none() && self.memory_mode.is_none() } @@ -888,6 +898,7 @@ mod tests { let mut current = ThreadMetadataPatch { name: Some(Some("old name".to_string())), preview: Some("old preview".to_string()), + is_pinned: Some(true), git_info: Some(GitInfoPatch { sha: Some(Some("abc123".to_string())), branch: Some(Some("main".to_string())), @@ -900,6 +911,7 @@ mod tests { name: Some(None), preview: None, title: Some("new title".to_string()), + is_pinned: Some(false), git_info: Some(GitInfoPatch { sha: None, branch: Some(Some("feature".to_string())), @@ -911,6 +923,7 @@ mod tests { assert_eq!(current.name, Some(None)); assert_eq!(current.preview.as_deref(), Some("old preview")); assert_eq!(current.title.as_deref(), Some("new title")); + assert_eq!(current.is_pinned, Some(false)); assert_eq!( current.git_info, Some(GitInfoPatch { diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index c6f24e6a327c..bb95ba29d278 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -142,6 +142,7 @@ mod tests { parent_thread_id: None, preview: String::new(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 5e6ec5381877..66eae6967103 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3439,6 +3439,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "agent-provider".to_string(), created_at: 1, @@ -3535,6 +3536,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "agent-provider".to_string(), created_at: 1, @@ -3598,6 +3600,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "read-provider".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 6758a81e8b33..aeadb631eb87 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -417,6 +417,7 @@ mod tests { parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "read-provider".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 410bf4f636f6..487778ca6c92 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -823,6 +823,7 @@ impl AppServerSession { request_id, params: ThreadMetadataUpdateParams { thread_id: thread_id.to_string(), + is_pinned: None, git_info: Some(ThreadMetadataGitInfoUpdateParams { sha: None, branch: Some(Some(branch)), @@ -2678,6 +2679,7 @@ mod tests { parent_thread_id: None, preview: "hello".to_string(), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: "openai".to_string(), created_at: 1, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 632907300482..83eb28f8a654 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -638,6 +638,7 @@ async fn lookup_session_target_by_name_with_app_server( model_providers: None, source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None, @@ -752,6 +753,7 @@ fn latest_session_lookup_params( }, source_kinds: Some(resume_source_kinds(include_non_interactive)), archived: Some(false), + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 46c99f565159..b93223e242a7 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1851,6 +1851,7 @@ fn thread_list_params( }, source_kinds: Some(crate::resume_source_kinds(include_non_interactive)), archived: Some(false), + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())), @@ -5757,6 +5758,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("remote thread"), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5796,6 +5798,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("preview"), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5873,6 +5876,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("preview"), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, @@ -5943,6 +5947,7 @@ session_picker_view = "dense" parent_thread_id: None, preview: String::from("preview"), ephemeral: false, + is_pinned: false, history_mode: Default::default(), model_provider: String::from("openai"), created_at: 1, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index d3e728f5f8c1..24e1a16c251b 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -179,6 +179,7 @@ async fn lookup_session_by_exact_name( /*include_non_interactive*/ false, )), archived: Some(archived), + is_pinned: None, parent_thread_id: None, ancestor_thread_id: None, cwd: None,