Add cwd as an optional field to thread/list (#11651)

Add's the ability to filter app-server thread/list by cwd
This commit is contained in:
acrognale-oai 2026-02-12 21:05:04 -05:00 committed by GitHub
parent 537102e657
commit ebe359b876
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 148 additions and 14 deletions

View file

@ -2565,6 +2565,13 @@
"null"
]
},
"cwd": {
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View file

@ -15334,6 +15334,13 @@
"null"
]
},
"cwd": {
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View file

@ -39,6 +39,13 @@
"null"
]
},
"cwd": {
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View file

@ -31,4 +31,9 @@ sourceKinds?: Array<ThreadSourceKind> | null,
* Optional archived filter; when set to true, only archived threads are returned.
* If false or null, only non-archived threads are returned.
*/
archived?: boolean | null, };
archived?: boolean | null,
/**
* Optional cwd filter; when set, only threads whose session cwd exactly
* matches this path are returned.
*/
cwd?: string | null, };

View file

@ -1708,6 +1708,10 @@ pub struct ThreadListParams {
/// If false or null, only non-archived threads are returned.
#[ts(optional = nullable)]
pub archived: Option<bool>,
/// Optional cwd filter; when set, only threads whose session cwd exactly
/// matches this path are returned.
#[ts(optional = nullable)]
pub cwd: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]

View file

@ -117,7 +117,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, and `cwd` filters.
- `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`.
- `thread/archive` — move a threads rollout file into the archived directory; returns `{}` on success.
@ -221,6 +221,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
- `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).
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
Example:

View file

@ -266,6 +266,13 @@ use crate::thread_state::ThreadStateManager;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
struct ThreadListFilters {
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: bool,
cwd: Option<PathBuf>,
}
// Duration before a ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
@ -2430,6 +2437,7 @@ impl CodexMessageProcessor {
model_providers,
source_kinds,
archived,
cwd,
} = params;
let requested_page_size = limit
@ -2444,10 +2452,13 @@ impl CodexMessageProcessor {
.list_threads_common(
requested_page_size,
cursor,
model_providers,
source_kinds,
core_sort_key,
archived.unwrap_or(false),
ThreadListFilters {
model_providers,
source_kinds,
archived: archived.unwrap_or(false),
cwd: cwd.map(PathBuf::from),
},
)
.await
{
@ -3221,10 +3232,13 @@ impl CodexMessageProcessor {
.list_threads_common(
requested_page_size,
cursor,
model_providers,
None,
CoreThreadSortKey::UpdatedAt,
false,
ThreadListFilters {
model_providers,
source_kinds: None,
archived: false,
cwd: None,
},
)
.await
{
@ -3242,11 +3256,15 @@ impl CodexMessageProcessor {
&self,
requested_page_size: usize,
cursor: Option<String>,
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
sort_key: CoreThreadSortKey,
archived: bool,
filters: ThreadListFilters,
) -> Result<(Vec<ConversationSummary>, Option<String>), JSONRPCErrorError> {
let ThreadListFilters {
model_providers,
source_kinds,
archived,
cwd,
} = filters;
let mut cursor_obj: Option<RolloutCursor> = match cursor.as_ref() {
Some(cursor_str) => {
Some(parse_cursor(cursor_str).ok_or_else(|| JSONRPCErrorError {
@ -3327,6 +3345,9 @@ impl CodexMessageProcessor {
if source_kind_filter
.as_ref()
.is_none_or(|filter| source_kind_matches(&summary.source, filter))
&& cwd
.as_ref()
.is_none_or(|expected_cwd| &summary.cwd == expected_cwd)
{
filtered.push(summary);
if filtered.len() >= remaining {

View file

@ -17,6 +17,8 @@ use codex_app_server_protocol::ThreadSourceKind;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
@ -66,6 +68,7 @@ async fn list_threads_with_sort(
model_providers: providers,
source_kinds,
archived,
cwd: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@ -127,6 +130,26 @@ fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
Ok(())
}
fn set_rollout_cwd(path: &Path, cwd: &Path) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let first_line = lines
.first_mut()
.ok_or_else(|| anyhow::anyhow!("rollout at {} is empty", path.display()))?;
let mut rollout_line: RolloutLine = serde_json::from_str(first_line)?;
let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else {
return Err(anyhow::anyhow!(
"rollout at {} does not start with session metadata",
path.display()
));
};
session_meta_line.meta.cwd = cwd.to_path_buf();
rollout_line.item = RolloutItem::SessionMeta(session_meta_line);
*first_line = serde_json::to_string(&rollout_line)?;
fs::write(path, lines.join("\n") + "\n")?;
Ok(())
}
#[tokio::test]
async fn thread_list_basic_empty() -> Result<()> {
let codex_home = TempDir::new()?;
@ -300,6 +323,63 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_list_respects_cwd_filter() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let filtered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"filtered",
Some("mock_provider"),
None,
)?;
let unfiltered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"unfiltered",
Some("mock_provider"),
None,
)?;
let target_cwd = codex_home.path().join("target-cwd");
fs::create_dir_all(&target_cwd)?;
set_rollout_cwd(
rollout_path(codex_home.path(), "2025-01-02T10-00-00", &filtered_id).as_path(),
&target_cwd,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: Some(target_cwd.to_string_lossy().into_owned()),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadListResponse { data, next_cursor } = to_response::<ThreadListResponse>(resp)?;
assert_eq!(next_cursor, None);
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, filtered_id);
assert_ne!(data[0].id, unfiltered_id);
assert_eq!(data[0].cwd, target_cwd);
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
@ -1107,6 +1187,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: None,
})
.await?;
let error: JSONRPCError = timeout(

View file

@ -180,6 +180,7 @@ impl AppServerClient {
model_providers: None,
source_kinds: None,
archived: None,
cwd: None,
},
};
self.send(&request)?;

View file

@ -81,9 +81,9 @@ Interrupt a running turn: `interruptConversation`.
List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`.
For v2 threads, use `thread/list` with `archived: true` to list archived rollouts and
`thread/unarchive` to restore them to the active sessions directory (it returns the restored
thread summary).
For v2 threads, use `thread/list` with filters such as `archived: true` or `cwd: "/path"` to
narrow results, and `thread/unarchive` to restore archived rollouts to the active sessions
directory (it returns the restored thread summary).
## Models