Add persisted thread pinning to the app server (#34840)

## What changed

- Add `isPinned` to thread responses and allow `thread/metadata/update` to pin or unpin stored threads.
- Add an `isPinned` filter to `thread/list`, including cursor-based pagination and combinations with relationship filters.
- Persist pin state in SQLite without modifying rollout files, default existing threads to unpinned, and preserve pins through reconciliation and archive transitions.

## Testing

- Cover protocol serialization, database migration and indexing, pin updates, filtered pagination, rollout reconciliation, and archive/unarchive behavior.

GitOrigin-RevId: c2eacabd6dae465e2ae6ce8a169e00740960cdb7
This commit is contained in:
joeytrasatti-openai
2026-07-22 22:54:28 +00:00
committed by copyberry
parent d7e8f4c3dc
commit 400ee190c3
71 changed files with 1058 additions and 60 deletions
@@ -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,
+1
View File
@@ -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,
@@ -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"
}
@@ -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"
@@ -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"
}
@@ -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"
}
@@ -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"
@@ -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",
@@ -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"
@@ -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"
}
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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, /**
@@ -30,6 +30,9 @@ sourceKinds?: Array<ThreadSourceKind> | 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.
*/
@@ -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, };
@@ -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,
@@ -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::<Thread>(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::<ThreadListParams>(json!({
"isPinned": is_pinned,
}))
.expect("pinned filter should deserialize");
assert_eq!(params.is_pinned, Some(is_pinned));
}
let params = serde_json::from_value::<ThreadListParams>(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::<ThreadMetadataUpdateParams>(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::<ThreadMetadataUpdateParams>(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!(
@@ -865,6 +865,9 @@ pub struct ThreadMetadataUpdateParams {
/// provide a string to replace the stored value.
#[ts(optional = nullable)]
pub git_info: Option<ThreadMetadataGitInfoUpdateParams>,
/// Patch whether this thread is pinned. Omit to leave the stored value unchanged.
#[ts(optional = nullable)]
pub is_pinned: Option<bool>,
}
#[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<bool>,
/// Optional pinned filter; when set, only threads matching this value are returned.
#[ts(optional = nullable)]
pub is_pinned: Option<bool>,
/// 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<string> | null")]
@@ -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)]
@@ -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,
+3 -2
View File
@@ -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).
@@ -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,
@@ -19,6 +19,7 @@ struct ThreadListFilters {
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: bool,
is_pinned: Option<bool>,
cwd_filters: Option<Vec<PathBuf>>,
search_term: Option<String>,
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,
@@ -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,
@@ -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,
@@ -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),
@@ -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,
@@ -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,
@@ -78,6 +78,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
model_providers: None,
source_kinds: None,
archived: None,
is_pinned: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -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,
@@ -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::<Vec<_>>(),
[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),
@@ -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,
@@ -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())),
@@ -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),
+1
View File
@@ -141,6 +141,7 @@ async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
cwd_filters: None,
relation_filter: None,
archived: false,
is_pinned: None,
search_term: None,
use_state_db_only: false,
})
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+3
View File
@@ -55,6 +55,8 @@ pub struct ThreadItem {
pub first_user_message: Option<String>,
/// Best available user-facing preview for discovery and list display.
pub preview: Option<String>,
/// Whether the thread is pinned in SQLite-owned metadata.
pub is_pinned: bool,
/// Working directory from session metadata.
pub cwd: Option<PathBuf>,
/// 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,
+8
View File
@@ -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,
+3
View File
@@ -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")
+2
View File
@@ -372,6 +372,7 @@ pub async fn list_threads_db(
cwd_filters: Option<&[PathBuf]>,
relation_filter: Option<codex_state::ThreadRelationFilter>,
archived: bool,
is_pinned: Option<bool>,
search_term: Option<&str>,
) -> Option<codex_state::ThreadsPage> {
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(),
+11
View File
@@ -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,
@@ -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 <> '';
+1
View File
@@ -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,
+73
View File
@@ -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();
@@ -124,6 +124,8 @@ pub struct ThreadMetadata {
pub first_user_message: Option<String>,
/// The archive timestamp, if the thread is archived.
pub archived_at: Option<DateTime<Utc>>,
/// Whether the thread was explicitly pinned by the user.
pub is_pinned: bool,
/// The git commit SHA, if known.
pub git_sha: Option<String>,
/// 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<i64>,
is_pinned: bool,
git_sha: Option<String>,
git_branch: Option<String>,
git_origin_url: Option<String>,
@@ -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<ThreadRow> for ThreadMetadata {
tokens_used,
first_user_message,
archived_at,
is_pinned,
git_sha,
git_branch,
git_origin_url,
@@ -514,6 +523,7 @@ impl TryFrom<ThreadRow> 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,
+3
View File
@@ -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
@@ -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,
+208 -2
View File
@@ -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<bool> {
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<bool>,
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::<Utc>::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<_>>(),
vec![newest_unpinned, oldest_unpinned]
);
let mut builder = QueryBuilder::<Sqlite>::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::<String, _>("detail"))
.collect::<Vec<_>>();
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,
+48
View File
@@ -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<_>>(),
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<_>>(),
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(),
@@ -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,
@@ -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,
+21 -11
View File
@@ -97,15 +97,17 @@ pub(super) async fn list_rollout_threads(
sort_key: codex_rollout::ThreadSortKey,
sort_direction: codex_rollout::SortDirection,
) -> ThreadStoreResult<codex_rollout::ThreadsPage> {
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,
@@ -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,
@@ -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,
@@ -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);
}
}
@@ -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<_>>(),
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,
+13
View File
@@ -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<Vec<PathBuf>>,
/// Optional persisted pin-state filter.
pub is_pinned: Option<bool>,
/// 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<Utc>,
/// Thread archive timestamp, if archived.
pub archived_at: Option<DateTime<Utc>>,
/// 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<TokenUsage>,
/// First user message observed for this thread.
pub first_user_message: Option<String>,
/// Replacement user-selected thread pin state.
pub is_pinned: Option<bool>,
/// Git metadata patch.
pub git_info: Option<GitInfoPatch>,
/// 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 {
+1
View File
@@ -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,
+3
View File
@@ -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,
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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())),
+5
View File
@@ -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,
@@ -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,