mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
## Description This PR adds a new `historyMode = "legacy" | "paginated"` to `Thread`. This will be stored in `SessionMeta` in the JSONL rollout file and as a new column in the SQLite thread_metadata table, and exposed on `thread/start` and on the `Thread` object in app-server. ## What changed - Added canonical `ThreadHistoryMode` with `legacy` and `paginated`, defaulting old and new SessionMeta to `legacy`. - Carried `history_mode` through core session config, ThreadStore stored metadata, local/in-memory stores, rollout metadata extraction, and the existing SQLite `threads` table. - Added experimental `historyMode` to app-server v2 `Thread` and `thread/start`. - Made paginated stored threads metadata-discoverable but unsupported for legacy full-history reads, `load_history`, live resume, and create paths. - Regenerated app-server schema fixtures and added protocol/state/thread-store/app-server coverage for persistence and fail-closed behavior. ## Compatibility floor Because users may be running various versions of Codex binaries on the same machine (TUI, Codex App, etc.), we will need to establish a compatibility floor for upcoming paginated threads, which will change how thread storage reads and writes work. The overall plan here: ``` Release N: - Add historyMode to SessionMeta / Thread / SQLite metadata. - Teach binaries to understand paginated threads. - If a binary sees `historyMode="paginated"` but does not support the paginated contract, it refuses to resume/mutate the thread. - Default remains `"legacy"`. Release N+1: - First-party clients start opting into paginated threads where appropriate. - Internal dogfood / staged rollout. - Measure old-client usage and paginated-thread unsupported errors. Release N+2: - Only after Release N+ is overwhelmingly deployed, make paginated the default. - Accept that a small tail of N-1-or-older binaries may not understand paginated threads. ``` The important behavior change is fail-closed handling for a binary that encounters a persisted `paginated` thread before it knows how to fully support paginated history. In app-server, if a thread is `paginated`, we will: - allow metadata-only discovery paths like `thread/list` and `thread/read(includeTurns=false)`, so clients can still see the thread and inspect its `historyMode` - reject legacy full-history/live-thread paths like `thread/read(includeTurns=true)` and `thread/resume` with an unsupported JSON-RPC error - avoid silently treating an unknown or future `historyMode` as `legacy` Under the hood, the ThreadStore layer also rejects legacy operations that would need to load or replay the full thread history for a paginated thread. That gives us the behavior we want for Release N: future paginated threads are visible, but this binary fails closed instead of trying to operate on them as if they were legacy threads.
236 lines
8.7 KiB
Rust
236 lines
8.7 KiB
Rust
//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges.
|
|
//!
|
|
//! When the TUI resumes or switches to an existing thread, it needs to populate
|
|
//! `AgentNavigationState` and `ChatWidget` metadata for every subagent that was spawned during
|
|
//! that thread's lifetime. The app server exposes a flat list of currently loaded threads via
|
|
//! `thread/loaded/list`, but the TUI must figure out which of those are descendants of the
|
|
//! primary thread.
|
|
//!
|
|
//! This module provides the pure, synchronous tree-walk that turns that flat list into the filtered
|
|
//! set of descendants. It intentionally has no async, no I/O, and no side effects so it can be
|
|
//! unit-tested in isolation.
|
|
//!
|
|
//! The walk starts from `primary_thread_id` and repeatedly follows
|
|
//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are
|
|
//! found. The primary thread itself is never included in the output.
|
|
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
|
|
/// A subagent thread discovered by the spawn-tree walk, carrying just enough metadata for the
|
|
/// TUI to register it in the navigation cache and rendering metadata map.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct LoadedSubagentThread {
|
|
pub(crate) thread_id: ThreadId,
|
|
pub(crate) agent_nickname: Option<String>,
|
|
pub(crate) agent_role: Option<String>,
|
|
pub(crate) agent_path: Option<String>,
|
|
}
|
|
|
|
/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent.
|
|
///
|
|
/// The walk is breadth-first over `SessionSource::SubAgent(ThreadSpawn { parent_thread_id })` edges.
|
|
/// Threads whose `source` is not a `ThreadSpawn`, or whose `parent_thread_id` does not chain back
|
|
/// to `primary_thread_id`, are excluded. The primary thread itself is never included.
|
|
///
|
|
/// Results are sorted by stringified thread id for deterministic output in tests and in the
|
|
/// navigation cache. Callers should not rely on this ordering for anything semantic; it exists
|
|
/// purely to make snapshot assertions stable.
|
|
///
|
|
/// If two threads claim the same parent, both are included. Cycles in the parent chain are not
|
|
/// possible because `ThreadId`s are server-assigned UUIDs and the server enforces acyclicity, but
|
|
/// the `included` set guards against re-visiting regardless.
|
|
pub(crate) fn find_loaded_subagent_threads_for_primary(
|
|
threads: Vec<Thread>,
|
|
primary_thread_id: ThreadId,
|
|
) -> Vec<LoadedSubagentThread> {
|
|
let mut threads_by_id = HashMap::new();
|
|
for thread in threads {
|
|
let Ok(thread_id) = ThreadId::from_string(&thread.id) else {
|
|
continue;
|
|
};
|
|
threads_by_id.insert(thread_id, thread);
|
|
}
|
|
|
|
let mut included = HashSet::new();
|
|
let mut pending = vec![primary_thread_id];
|
|
while let Some(parent_thread_id) = pending.pop() {
|
|
for (thread_id, thread) in &threads_by_id {
|
|
if included.contains(thread_id) {
|
|
continue;
|
|
}
|
|
|
|
let Some(source_parent_thread_id) = thread_spawn_parent_thread_id(&thread.source)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if source_parent_thread_id != parent_thread_id {
|
|
continue;
|
|
}
|
|
|
|
included.insert(*thread_id);
|
|
pending.push(*thread_id);
|
|
}
|
|
}
|
|
|
|
let mut loaded_threads: Vec<LoadedSubagentThread> = included
|
|
.into_iter()
|
|
.filter_map(|thread_id| {
|
|
threads_by_id
|
|
.remove(&thread_id)
|
|
.map(|thread| LoadedSubagentThread {
|
|
thread_id,
|
|
agent_nickname: thread.agent_nickname,
|
|
agent_role: thread.agent_role,
|
|
agent_path: thread_spawn_agent_path(&thread.source),
|
|
})
|
|
})
|
|
.collect();
|
|
loaded_threads.sort_by_key(|thread| thread.thread_id.to_string());
|
|
loaded_threads
|
|
}
|
|
|
|
fn thread_spawn_agent_path(source: &SessionSource) -> Option<String> {
|
|
match source {
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
|
|
agent_path.clone().map(String::from)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn thread_spawn_parent_thread_id(source: &SessionSource) -> Option<ThreadId> {
|
|
match source {
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id, ..
|
|
}) => Some(*parent_thread_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::LoadedSubagentThread;
|
|
use super::find_loaded_subagent_threads_for_primary;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_protocol::ThreadId;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread {
|
|
Thread {
|
|
id: thread_id.to_string(),
|
|
extra: None,
|
|
session_id: thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: String::new(),
|
|
ephemeral: false,
|
|
history_mode: Default::default(),
|
|
model_provider: "openai".to_string(),
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
recency_at: Some(0),
|
|
status: ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn thread_spawn_source(
|
|
parent_thread_id: ThreadId,
|
|
depth: i32,
|
|
agent_nickname: &str,
|
|
agent_role: &str,
|
|
) -> SessionSource {
|
|
serde_json::from_value(serde_json::json!({
|
|
"subAgent": {
|
|
"thread_spawn": {
|
|
"parent_thread_id": parent_thread_id.to_string(),
|
|
"depth": depth,
|
|
"agent_nickname": agent_nickname,
|
|
"agent_role": agent_role,
|
|
}
|
|
}
|
|
}))
|
|
.expect("valid subagent source")
|
|
}
|
|
|
|
#[test]
|
|
fn finds_loaded_subagent_tree_for_primary_thread() {
|
|
let primary_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
|
|
let child_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
|
|
let grandchild_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread");
|
|
let unrelated_parent_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000004").expect("valid thread");
|
|
let unrelated_child_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000005").expect("valid thread");
|
|
|
|
let mut child = test_thread(
|
|
child_thread_id,
|
|
thread_spawn_source(primary_thread_id, /*depth*/ 1, "Scout", "explorer"),
|
|
);
|
|
child.agent_nickname = Some("Scout".to_string());
|
|
child.agent_role = Some("explorer".to_string());
|
|
|
|
let mut grandchild = test_thread(
|
|
grandchild_thread_id,
|
|
thread_spawn_source(child_thread_id, /*depth*/ 2, "Atlas", "worker"),
|
|
);
|
|
grandchild.agent_nickname = Some("Atlas".to_string());
|
|
grandchild.agent_role = Some("worker".to_string());
|
|
|
|
let unrelated_child = test_thread(
|
|
unrelated_child_id,
|
|
thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"),
|
|
);
|
|
|
|
let loaded = find_loaded_subagent_threads_for_primary(
|
|
vec![
|
|
test_thread(primary_thread_id, SessionSource::Cli),
|
|
child,
|
|
grandchild,
|
|
unrelated_child,
|
|
],
|
|
primary_thread_id,
|
|
);
|
|
|
|
assert_eq!(
|
|
loaded,
|
|
vec![
|
|
LoadedSubagentThread {
|
|
thread_id: child_thread_id,
|
|
agent_nickname: Some("Scout".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
agent_path: None,
|
|
},
|
|
LoadedSubagentThread {
|
|
thread_id: grandchild_thread_id,
|
|
agent_nickname: Some("Atlas".to_string()),
|
|
agent_role: Some("worker".to_string()),
|
|
agent_path: None,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
}
|