Move message history out of core (#21278)

## 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`
This commit is contained in:
pakrym-oai authored and GitHub committed 2026-05-06 08:35:42 -07:00
1 parent be1d3cff93
commit 2004173cd7
45 files changed
+375 -492

No files matched your search

@@ -29,8 +29,7 @@ async fn submission_preserves_text_elements_and_local_images() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -133,8 +132,7 @@ async fn submission_includes_configured_permission_profile() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -183,8 +181,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -225,8 +222,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -319,8 +315,7 @@ async fn enter_with_only_remote_images_submits_user_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -383,8 +378,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -422,8 +416,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -461,8 +454,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -503,8 +495,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -958,8 +958,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -977,8 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
other => panic!("expected RunUserShellCommand op, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "!echo hi"
rx.try_recv(),
Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi"
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
@@ -31,8 +31,7 @@ async fn resumed_initial_messages_render_history() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -102,8 +101,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -171,8 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -271,8 +268,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
cwd: expected_cwd.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
};
@@ -328,8 +324,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
cwd: test_path_buf("/home/user/external").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
};
@@ -367,8 +362,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -422,8 +416,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -693,8 +686,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
});
@@ -739,8 +731,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
});
@@ -586,8 +586,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
@@ -634,8 +633,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
cwd,
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
@@ -1219,8 +1219,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -1463,8 +1462,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
};
@@ -38,14 +38,16 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane.composer_text()
}
fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> String {
fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> String {
loop {
match op_rx.try_recv() {
Ok(Op::AddToHistory { text }) => return text,
match rx.try_recv() {
Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) => return text,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"),
Err(TryRecvError::Empty) => {
panic!("expected AppendMessageHistoryEntry event but queue was empty")
}
Err(TryRecvError::Disconnected) => {
panic!("expected AddToHistory op but channel closed")
panic!("expected AppendMessageHistoryEntry event but channel closed")
}
}
}
@@ -116,15 +118,6 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() {
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
match op_rx.try_recv() {
Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() {
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
instructions: "check regressions".to_string(),
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
},
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
@@ -152,7 +145,7 @@ async fn queued_slash_review_with_args_restores_for_edit() {
#[tokio::test]
async fn queued_bang_shell_dispatches_after_active_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
@@ -171,10 +164,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "!echo hi"
);
assert_eq!(next_add_to_history_event(&mut rx), "!echo hi");
assert!(chat.queued_user_messages.is_empty());
}
@@ -217,7 +207,7 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu
#[tokio::test]
async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
@@ -230,10 +220,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "!echo hi"
);
assert_eq!(next_add_to_history_event(&mut rx), "!echo hi");
assert_eq!(chat.queued_user_messages.len(), 1);
let begin = begin_exec_with_source(
@@ -412,10 +399,10 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
),
other => panic!("expected first queued message after /rename, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "first after rename"
);
assert!(events.iter().any(|event| matches!(
event,
AppEvent::AppendMessageHistoryEntry { text, .. } if text == "first after rename"
)));
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
@@ -945,7 +932,7 @@ fn merged_history_record_remaps_override_image_placeholders() {
#[tokio::test]
async fn interrupted_merged_message_history_encodes_mentions_once() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
@@ -977,7 +964,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() {
other => panic!("expected user turn, got {other:?}"),
}
let encoded = "use [$figma](app://figma) now";
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
assert_eq!(next_add_to_history_event(&mut rx), encoded);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
@@ -997,7 +984,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() {
}
other => panic!("expected resubmitted user turn, got {other:?}"),
}
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
assert_eq!(next_add_to_history_event(&mut rx), encoded);
}
#[tokio::test]
@@ -1863,8 +1863,7 @@ async fn session_configured_clears_goal_status_footer() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
});