mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5267e805fb
## 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.
338 lines
11 KiB
Rust
338 lines
11 KiB
Rust
use super::CURRENT_THREAD_SECTION_TOKEN_BUDGET;
|
|
use super::NOTES_SECTION_TOKEN_BUDGET;
|
|
use super::RECENT_WORK_SECTION_TOKEN_BUDGET;
|
|
use super::STARTUP_CONTEXT_HEADER;
|
|
use super::WORKSPACE_SECTION_TOKEN_BUDGET;
|
|
use super::build_current_thread_section;
|
|
use super::build_recent_work_section;
|
|
use super::build_workspace_section_with_user_root;
|
|
use super::format_section;
|
|
use super::format_startup_context_blob;
|
|
use chrono::TimeZone;
|
|
use chrono::Utc;
|
|
use codex_git_utils::GitSha;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::GitInfo;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_thread_store::StoredThread;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThread {
|
|
StoredThread {
|
|
thread_id: ThreadId::new(),
|
|
extra_config: None,
|
|
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: first_user_message.to_string(),
|
|
name: (!title.is_empty()).then(|| title.to_string()),
|
|
model_provider: "test-provider".to_string(),
|
|
model: Some("gpt-5.2".to_string()),
|
|
reasoning_effort: None,
|
|
created_at: Utc
|
|
.timestamp_opt(1_709_251_100, 0)
|
|
.single()
|
|
.expect("valid timestamp"),
|
|
updated_at: Utc
|
|
.timestamp_opt(1_709_251_200, 0)
|
|
.single()
|
|
.expect("valid timestamp"),
|
|
recency_at: Utc
|
|
.timestamp_opt(1_709_251_200, 0)
|
|
.single()
|
|
.expect("valid timestamp"),
|
|
archived_at: None,
|
|
cwd: PathBuf::from(cwd),
|
|
cli_version: "test".to_string(),
|
|
source: SessionSource::Cli,
|
|
history_mode: Default::default(),
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
agent_path: None,
|
|
git_info: Some(GitInfo {
|
|
commit_hash: Some(GitSha::new("abcdef")),
|
|
branch: Some("main".to_string()),
|
|
repository_url: None,
|
|
}),
|
|
approval_mode: AskForApproval::Never,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
token_usage: None,
|
|
first_user_message: Some(first_user_message.to_string()),
|
|
history: None,
|
|
}
|
|
}
|
|
|
|
fn message(role: &str, content: ContentItem) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: role.to_string(),
|
|
content: vec![content],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
|
|
fn user_message(text: impl Into<String>) -> ResponseItem {
|
|
message("user", ContentItem::InputText { text: text.into() })
|
|
}
|
|
|
|
fn assistant_message(text: impl Into<String>) -> ResponseItem {
|
|
message("assistant", ContentItem::OutputText { text: text.into() })
|
|
}
|
|
|
|
fn long_turn_text(index: usize) -> String {
|
|
format!(
|
|
"turn-{index}-start {} turn-{index}-middle {} turn-{index}-end",
|
|
"head filler ".repeat(160),
|
|
"tail filler ".repeat(240),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn current_thread_section_includes_short_turns_newest_first_until_budget() {
|
|
let items = vec![
|
|
user_message("user turn 1"),
|
|
assistant_message("assistant turn 1"),
|
|
user_message("user turn 2"),
|
|
assistant_message("assistant turn 2"),
|
|
user_message("user turn 3"),
|
|
assistant_message("assistant turn 3"),
|
|
user_message("user turn 4"),
|
|
assistant_message("assistant turn 4"),
|
|
];
|
|
|
|
assert_eq!(
|
|
build_current_thread_section(&items),
|
|
Some(
|
|
r#"Most recent user/assistant turns from this exact thread. Use them for continuity when responding.
|
|
|
|
### Latest turn
|
|
User:
|
|
user turn 4
|
|
|
|
Assistant:
|
|
assistant turn 4
|
|
|
|
### Previous turn 1
|
|
User:
|
|
user turn 3
|
|
|
|
Assistant:
|
|
assistant turn 3
|
|
|
|
### Previous turn 2
|
|
User:
|
|
user turn 2
|
|
|
|
Assistant:
|
|
assistant turn 2
|
|
|
|
### Previous turn 3
|
|
User:
|
|
user turn 1
|
|
|
|
Assistant:
|
|
assistant turn 1"#
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_thread_turn_truncation_preserves_start_and_end() {
|
|
let items = vec![user_message(long_turn_text(/*index*/ 0))];
|
|
let section = build_current_thread_section(&items).expect("current thread section");
|
|
|
|
assert_eq!(
|
|
(
|
|
section.contains("turn-0-start"),
|
|
section.contains("turn-0-middle"),
|
|
section.contains("turn-0-end"),
|
|
section.contains("tokens truncated"),
|
|
),
|
|
(true, false, true, true),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_thread_section_keeps_latest_turns_when_history_exceeds_budget() {
|
|
let mut items = Vec::new();
|
|
for index in 1..=8 {
|
|
items.push(user_message(long_turn_text(index)));
|
|
items.push(assistant_message(format!("assistant turn {index}")));
|
|
}
|
|
|
|
let section = build_current_thread_section(&items).expect("current thread section");
|
|
|
|
assert_eq!(
|
|
(
|
|
section.contains("turn-8-start"),
|
|
section.contains("turn-8-end"),
|
|
section.contains("### Previous turn 2"),
|
|
section.contains("turn-1-start"),
|
|
section.contains("turn-1-end"),
|
|
),
|
|
(true, true, true, false, false),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_context_blob_is_wrapped_in_tags_without_final_truncation() {
|
|
let body = "Startup context from Codex.\n## Current Thread\nhello";
|
|
let wrapped = format_startup_context_blob(body);
|
|
|
|
assert_eq!(
|
|
wrapped,
|
|
"<startup_context>\nStartup context from Codex.\n## Current Thread\nhello\n</startup_context>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fixed_section_budgets_apply_per_section_without_total_blob_truncation() {
|
|
let body = [
|
|
STARTUP_CONTEXT_HEADER.to_string(),
|
|
format_section(
|
|
"Current Thread",
|
|
Some("current thread ".repeat(2_000)),
|
|
CURRENT_THREAD_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("current thread section"),
|
|
format_section(
|
|
"Recent Work",
|
|
Some("recent work ".repeat(3_000)),
|
|
RECENT_WORK_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("recent work section"),
|
|
format_section(
|
|
"Machine / Workspace Map",
|
|
Some("workspace map ".repeat(2_500)),
|
|
WORKSPACE_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("workspace section"),
|
|
format_section(
|
|
"Notes",
|
|
Some("notes ".repeat(500)),
|
|
NOTES_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("notes section"),
|
|
]
|
|
.join("\n\n");
|
|
|
|
let wrapped = format_startup_context_blob(&body);
|
|
|
|
assert!(wrapped.starts_with("<startup_context>\n"));
|
|
assert!(wrapped.ends_with("\n</startup_context>"));
|
|
assert!(wrapped.contains("tokens truncated"));
|
|
assert!(wrapped.contains("## Current Thread"));
|
|
assert!(wrapped.contains("## Recent Work"));
|
|
assert!(wrapped.contains("## Machine / Workspace Map"));
|
|
assert!(wrapped.contains("## Notes"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_section_requires_meaningful_structure() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
assert_eq!(
|
|
build_workspace_section_with_user_root(&cwd.path().abs(), /*user_root*/ None).await,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_section_includes_tree_when_entries_exist() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
|
|
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
|
|
|
|
let section =
|
|
build_workspace_section_with_user_root(&cwd.path().abs(), /*user_root*/ None)
|
|
.await
|
|
.expect("workspace section");
|
|
assert!(section.contains("Working directory tree:"));
|
|
assert!(section.contains("- docs/"));
|
|
assert!(section.contains("- README.md"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_section_includes_user_root_tree_when_distinct() {
|
|
let root = TempDir::new().expect("tempdir");
|
|
let cwd = root.path().join("cwd");
|
|
let git_root = root.path().join("git");
|
|
let user_root = root.path().join("home");
|
|
|
|
fs::create_dir_all(cwd.join("docs")).expect("create cwd docs dir");
|
|
fs::write(cwd.join("README.md"), "hello").expect("write cwd readme");
|
|
fs::create_dir_all(git_root.join(".git")).expect("create git dir");
|
|
fs::write(git_root.join("Cargo.toml"), "[workspace]").expect("write git root marker");
|
|
fs::create_dir_all(user_root.join("code")).expect("create user root child");
|
|
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
|
|
|
|
let section = build_workspace_section_with_user_root(&cwd.abs(), Some(user_root))
|
|
.await
|
|
.expect("workspace section");
|
|
assert!(section.contains("User root tree:"));
|
|
assert!(section.contains("- code/"));
|
|
assert!(!section.contains("- .zshrc"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recent_work_section_groups_threads_by_cwd() {
|
|
let root = TempDir::new().expect("tempdir");
|
|
let repo = root.path().join("repo");
|
|
let workspace_a = repo.join("workspace-a");
|
|
let workspace_b = repo.join("workspace-b");
|
|
let outside = root.path().join("outside");
|
|
|
|
fs::create_dir(&repo).expect("create repo dir");
|
|
Command::new("git")
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.args(["init"])
|
|
.current_dir(&repo)
|
|
.output()
|
|
.expect("git init");
|
|
fs::create_dir_all(&workspace_a).expect("create workspace a");
|
|
fs::create_dir_all(&workspace_b).expect("create workspace b");
|
|
fs::create_dir_all(&outside).expect("create outside dir");
|
|
|
|
let recent_threads = vec![
|
|
stored_thread(
|
|
workspace_a.to_string_lossy().as_ref(),
|
|
"Investigate realtime startup context",
|
|
"Log the startup context before sending it",
|
|
),
|
|
stored_thread(
|
|
workspace_b.to_string_lossy().as_ref(),
|
|
"Trim websocket startup payload",
|
|
"Remove memories from the realtime startup context",
|
|
),
|
|
stored_thread(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"),
|
|
];
|
|
let current_cwd = workspace_a;
|
|
let repo = repo.abs();
|
|
|
|
let section = build_recent_work_section(¤t_cwd.abs(), &recent_threads)
|
|
.await
|
|
.expect("recent work section");
|
|
assert!(section.contains(&format!("### Git repo: {}", repo.display())));
|
|
assert!(section.contains("Recent sessions: 2"));
|
|
assert!(section.contains("User asks:"));
|
|
assert!(section.contains(&format!(
|
|
"- {}: Log the startup context before sending it",
|
|
current_cwd.display()
|
|
)));
|
|
assert!(section.contains(&format!("### Directory: {}", outside.display())));
|
|
assert!(section.contains(&format!("- {}: Inspect flaky test", outside.display())));
|
|
}
|