mirror of
https://github.com/openai/codex.git
synced 2026-05-28 06:55:01 +00:00
## Why Message history was implemented inside `codex-core` and surfaced through core protocol ops and `SessionConfiguredEvent` fields even though the current consumer is TUI-local prompt recall. That made core own UI history persistence and exposed `history_log_id` / `history_entry_count` through surfaces that app-server and other clients do not need. This change moves message history persistence out of core and keeps the recall plumbing local to the TUI. ## What changed - Added a new `codex-message-history` crate for appending, looking up, trimming, and reading metadata from `history.jsonl`. - Removed core protocol history ops/events: `AddToHistory`, `GetHistoryEntryRequest`, and `GetHistoryEntryResponse`. - Removed `history_log_id` and `history_entry_count` from `SessionConfiguredEvent` and updated exec/MCP/test fixtures accordingly. - Updated the TUI to dispatch local app events for message-history append/lookup and keep its persistent-history metadata in TUI session state. ## Validation - `cargo test -p codex-message-history -p codex-protocol` - `cargo test -p codex-exec event_processor_with_json_output` - `cargo test -p codex-mcp-server outgoing_message` - `cargo test -p codex-tui` - `just fix -p codex-message-history -p codex-protocol -p codex-core -p codex-tui -p codex-exec -p codex-mcp-server`
199 lines
6.2 KiB
Rust
199 lines
6.2 KiB
Rust
use super::*;
|
|
use codex_config::types::History;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use tempfile::TempDir;
|
|
|
|
#[tokio::test]
|
|
async fn lookup_reads_history_entries() {
|
|
let temp_dir = TempDir::new().expect("create temp dir");
|
|
let history_path = temp_dir.path().join(HISTORY_FILENAME);
|
|
|
|
let entries = vec![
|
|
HistoryEntry {
|
|
session_id: "first-session".to_string(),
|
|
ts: 1,
|
|
text: "first".to_string(),
|
|
},
|
|
HistoryEntry {
|
|
session_id: "second-session".to_string(),
|
|
ts: 2,
|
|
text: "second".to_string(),
|
|
},
|
|
];
|
|
|
|
let mut file = File::create(&history_path).expect("create history file");
|
|
for entry in &entries {
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::to_string(entry).expect("serialize history entry")
|
|
)
|
|
.expect("write history entry");
|
|
}
|
|
|
|
let (log_id, count) = history_metadata_for_file(&history_path).await;
|
|
assert_eq!(count, entries.len());
|
|
|
|
let second_entry = lookup_history_entry(&history_path, log_id, /*offset*/ 1)
|
|
.expect("fetch second history entry");
|
|
assert_eq!(second_entry, entries[1]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lookup_uses_stable_log_id_after_appends() {
|
|
let temp_dir = TempDir::new().expect("create temp dir");
|
|
let history_path = temp_dir.path().join(HISTORY_FILENAME);
|
|
|
|
let initial = HistoryEntry {
|
|
session_id: "first-session".to_string(),
|
|
ts: 1,
|
|
text: "first".to_string(),
|
|
};
|
|
let appended = HistoryEntry {
|
|
session_id: "second-session".to_string(),
|
|
ts: 2,
|
|
text: "second".to_string(),
|
|
};
|
|
|
|
let mut file = File::create(&history_path).expect("create history file");
|
|
writeln!(
|
|
file,
|
|
"{}",
|
|
serde_json::to_string(&initial).expect("serialize initial entry")
|
|
)
|
|
.expect("write initial entry");
|
|
|
|
let (log_id, count) = history_metadata_for_file(&history_path).await;
|
|
assert_eq!(count, 1);
|
|
|
|
let mut append = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(&history_path)
|
|
.expect("open history file for append");
|
|
writeln!(
|
|
append,
|
|
"{}",
|
|
serde_json::to_string(&appended).expect("serialize appended entry")
|
|
)
|
|
.expect("append history entry");
|
|
|
|
let fetched = lookup_history_entry(&history_path, log_id, /*offset*/ 1)
|
|
.expect("lookup appended history entry");
|
|
assert_eq!(fetched, appended);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_entry_trims_history_when_beyond_max_bytes() {
|
|
let codex_home = TempDir::new().expect("create temp dir");
|
|
let mut history = History::default();
|
|
let mut config = HistoryConfig::new(codex_home.path(), &history);
|
|
let conversation_id = "conversation-id";
|
|
|
|
let entry_one = "a".repeat(200);
|
|
let entry_two = "b".repeat(200);
|
|
|
|
let history_path = codex_home.path().join("history.jsonl");
|
|
|
|
append_entry(&entry_one, &conversation_id, &config)
|
|
.await
|
|
.expect("write first entry");
|
|
|
|
let first_len = std::fs::metadata(&history_path).expect("metadata").len();
|
|
let limit_bytes = first_len + 10;
|
|
|
|
history.max_bytes = Some(usize::try_from(limit_bytes).expect("limit should fit into usize"));
|
|
config = HistoryConfig::new(codex_home.path(), &history);
|
|
|
|
append_entry(&entry_two, &conversation_id, &config)
|
|
.await
|
|
.expect("write second entry");
|
|
|
|
let contents = std::fs::read_to_string(&history_path).expect("read history");
|
|
|
|
let entries = contents
|
|
.lines()
|
|
.map(|line| serde_json::from_str::<HistoryEntry>(line).expect("parse entry"))
|
|
.collect::<Vec<HistoryEntry>>();
|
|
|
|
assert_eq!(
|
|
entries.len(),
|
|
1,
|
|
"only one entry left because entry_one should be evicted"
|
|
);
|
|
assert_eq!(entries[0].text, entry_two);
|
|
assert!(std::fs::metadata(&history_path).expect("metadata").len() <= limit_bytes);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_entry_trims_history_to_soft_cap() {
|
|
let codex_home = TempDir::new().expect("create temp dir");
|
|
let mut history = History::default();
|
|
let mut config = HistoryConfig::new(codex_home.path(), &history);
|
|
let conversation_id = "conversation-id";
|
|
|
|
let short_entry = "a".repeat(200);
|
|
let long_entry = "b".repeat(400);
|
|
|
|
let history_path = codex_home.path().join("history.jsonl");
|
|
|
|
append_entry(&short_entry, &conversation_id, &config)
|
|
.await
|
|
.expect("write first entry");
|
|
|
|
let short_entry_len = std::fs::metadata(&history_path).expect("metadata").len();
|
|
|
|
append_entry(&long_entry, &conversation_id, &config)
|
|
.await
|
|
.expect("write second entry");
|
|
|
|
let two_entry_len = std::fs::metadata(&history_path).expect("metadata").len();
|
|
|
|
let long_entry_len = two_entry_len
|
|
.checked_sub(short_entry_len)
|
|
.expect("second entry length should be larger than first entry length");
|
|
|
|
history.max_bytes = Some(
|
|
usize::try_from((2 * long_entry_len) + (short_entry_len / 2))
|
|
.expect("max bytes should fit into usize"),
|
|
);
|
|
config = HistoryConfig::new(codex_home.path(), &history);
|
|
|
|
append_entry(&long_entry, &conversation_id, &config)
|
|
.await
|
|
.expect("write third entry");
|
|
|
|
let contents = std::fs::read_to_string(&history_path).expect("read history");
|
|
|
|
let entries = contents
|
|
.lines()
|
|
.map(|line| serde_json::from_str::<HistoryEntry>(line).expect("parse entry"))
|
|
.collect::<Vec<HistoryEntry>>();
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].text, long_entry);
|
|
|
|
let pruned_len = std::fs::metadata(&history_path).expect("metadata").len();
|
|
let max_bytes = config.max_bytes.expect("max bytes should be configured") as u64;
|
|
|
|
assert!(pruned_len <= max_bytes);
|
|
|
|
let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO)
|
|
.floor()
|
|
.clamp(1.0, max_bytes as f64) as u64;
|
|
let len_without_first = 2 * long_entry_len;
|
|
|
|
assert!(
|
|
len_without_first <= max_bytes,
|
|
"dropping only the first entry would satisfy the hard cap"
|
|
);
|
|
assert!(
|
|
len_without_first > soft_cap_bytes,
|
|
"soft cap should require more aggressive trimming than the hard cap"
|
|
);
|
|
|
|
assert_eq!(pruned_len, long_entry_len);
|
|
assert!(pruned_len <= soft_cap_bytes.max(long_entry_len));
|
|
}
|