Skip to content

Commit bbea6bb

Browse files
authored
Handle resuming/forking after compact (#3533)
We need to construct the history different when compact happens. For this, we need to just consider the history after compact and convert compact to a response item. This needs to change and use `build_compact_history` when this #3446 is merged.
1 parent 4891ee2 commit bbea6bb

File tree

6 files changed

+1118
-46
lines changed

6 files changed

+1118
-46
lines changed

codex-rs/core/src/codex.rs

Lines changed: 251 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ use codex_protocol::models::ShellToolCallParams;
130130
use codex_protocol::protocol::InitialHistory;
131131

132132
mod compact;
133+
use self::compact::build_compacted_history;
134+
use self::compact::collect_user_messages;
133135

134136
// A convenience extension trait for acquiring mutex locks where poisoning is
135137
// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
@@ -205,7 +207,7 @@ impl Codex {
205207
config.clone(),
206208
auth_manager.clone(),
207209
tx_event.clone(),
208-
conversation_history.clone(),
210+
conversation_history,
209211
)
210212
.await
211213
.map_err(|e| {
@@ -564,9 +566,10 @@ impl Session {
564566
let persist = matches!(conversation_history, InitialHistory::Forked(_));
565567

566568
// Always add response items to conversation history
567-
let response_items = conversation_history.get_response_items();
568-
if !response_items.is_empty() {
569-
self.record_into_history(&response_items);
569+
let reconstructed_history =
570+
self.reconstruct_history_from_rollout(turn_context, &rollout_items);
571+
if !reconstructed_history.is_empty() {
572+
self.record_into_history(&reconstructed_history);
570573
}
571574

572575
// If persisting, persist all rollout items as-is (recorder filters)
@@ -678,6 +681,33 @@ impl Session {
678681
self.persist_rollout_response_items(items).await;
679682
}
680683

684+
fn reconstruct_history_from_rollout(
685+
&self,
686+
turn_context: &TurnContext,
687+
rollout_items: &[RolloutItem],
688+
) -> Vec<ResponseItem> {
689+
let mut history = ConversationHistory::new();
690+
for item in rollout_items {
691+
match item {
692+
RolloutItem::ResponseItem(response_item) => {
693+
history.record_items(std::iter::once(response_item));
694+
}
695+
RolloutItem::Compacted(compacted) => {
696+
let snapshot = history.contents();
697+
let user_messages = collect_user_messages(&snapshot);
698+
let rebuilt = build_compacted_history(
699+
self.build_initial_context(turn_context),
700+
&user_messages,
701+
&compacted.message,
702+
);
703+
history.replace(rebuilt);
704+
}
705+
_ => {}
706+
}
707+
}
708+
history.contents()
709+
}
710+
681711
/// Append ResponseItems to the in-memory conversation history only.
682712
fn record_into_history(&self, items: &[ResponseItem]) {
683713
self.state
@@ -3220,18 +3250,59 @@ async fn exit_review_mode(
32203250
#[cfg(test)]
32213251
mod tests {
32223252
use super::*;
3253+
use crate::config::ConfigOverrides;
3254+
use crate::config::ConfigToml;
3255+
use crate::protocol::CompactedItem;
3256+
use crate::protocol::InitialHistory;
3257+
use crate::protocol::ResumedHistory;
3258+
use codex_protocol::models::ContentItem;
32233259
use mcp_types::ContentBlock;
32243260
use mcp_types::TextContent;
32253261
use pretty_assertions::assert_eq;
32263262
use serde_json::json;
3263+
use std::path::PathBuf;
3264+
use std::sync::Arc;
32273265
use std::time::Duration as StdDuration;
32283266

3229-
fn text_block(s: &str) -> ContentBlock {
3230-
ContentBlock::TextContent(TextContent {
3231-
annotations: None,
3232-
text: s.to_string(),
3233-
r#type: "text".to_string(),
3234-
})
3267+
#[test]
3268+
fn reconstruct_history_matches_live_compactions() {
3269+
let (session, turn_context) = make_session_and_context();
3270+
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
3271+
3272+
let reconstructed = session.reconstruct_history_from_rollout(&turn_context, &rollout_items);
3273+
3274+
assert_eq!(expected, reconstructed);
3275+
}
3276+
3277+
#[test]
3278+
fn record_initial_history_reconstructs_resumed_transcript() {
3279+
let (session, turn_context) = make_session_and_context();
3280+
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
3281+
3282+
tokio_test::block_on(session.record_initial_history(
3283+
&turn_context,
3284+
InitialHistory::Resumed(ResumedHistory {
3285+
conversation_id: ConversationId::default(),
3286+
history: rollout_items,
3287+
rollout_path: PathBuf::from("/tmp/resume.jsonl"),
3288+
}),
3289+
));
3290+
3291+
let actual = session.state.lock_unchecked().history.contents();
3292+
assert_eq!(expected, actual);
3293+
}
3294+
3295+
#[test]
3296+
fn record_initial_history_reconstructs_forked_transcript() {
3297+
let (session, turn_context) = make_session_and_context();
3298+
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
3299+
3300+
tokio_test::block_on(
3301+
session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)),
3302+
);
3303+
3304+
let actual = session.state.lock_unchecked().history.contents();
3305+
assert_eq!(expected, actual);
32353306
}
32363307

32373308
#[test]
@@ -3386,4 +3457,174 @@ mod tests {
33863457

33873458
assert_eq!(expected, got);
33883459
}
3460+
3461+
fn text_block(s: &str) -> ContentBlock {
3462+
ContentBlock::TextContent(TextContent {
3463+
annotations: None,
3464+
text: s.to_string(),
3465+
r#type: "text".to_string(),
3466+
})
3467+
}
3468+
3469+
fn make_session_and_context() -> (Session, TurnContext) {
3470+
let (tx_event, _rx_event) = async_channel::unbounded();
3471+
let codex_home = tempfile::tempdir().expect("create temp dir");
3472+
let config = Config::load_from_base_config_with_overrides(
3473+
ConfigToml::default(),
3474+
ConfigOverrides::default(),
3475+
codex_home.path().to_path_buf(),
3476+
)
3477+
.expect("load default test config");
3478+
let config = Arc::new(config);
3479+
let conversation_id = ConversationId::default();
3480+
let client = ModelClient::new(
3481+
config.clone(),
3482+
None,
3483+
config.model_provider.clone(),
3484+
config.model_reasoning_effort,
3485+
config.model_reasoning_summary,
3486+
conversation_id,
3487+
);
3488+
let tools_config = ToolsConfig::new(&ToolsConfigParams {
3489+
model_family: &config.model_family,
3490+
approval_policy: config.approval_policy,
3491+
sandbox_policy: config.sandbox_policy.clone(),
3492+
include_plan_tool: config.include_plan_tool,
3493+
include_apply_patch_tool: config.include_apply_patch_tool,
3494+
include_web_search_request: config.tools_web_search_request,
3495+
use_streamable_shell_tool: config.use_experimental_streamable_shell_tool,
3496+
include_view_image_tool: config.include_view_image_tool,
3497+
experimental_unified_exec_tool: config.use_experimental_unified_exec_tool,
3498+
});
3499+
let turn_context = TurnContext {
3500+
client,
3501+
cwd: config.cwd.clone(),
3502+
base_instructions: config.base_instructions.clone(),
3503+
user_instructions: config.user_instructions.clone(),
3504+
approval_policy: config.approval_policy,
3505+
sandbox_policy: config.sandbox_policy.clone(),
3506+
shell_environment_policy: config.shell_environment_policy.clone(),
3507+
tools_config,
3508+
is_review_mode: false,
3509+
};
3510+
let session = Session {
3511+
conversation_id,
3512+
tx_event,
3513+
mcp_connection_manager: McpConnectionManager::default(),
3514+
session_manager: ExecSessionManager::default(),
3515+
unified_exec_manager: UnifiedExecSessionManager::default(),
3516+
notify: None,
3517+
rollout: Mutex::new(None),
3518+
state: Mutex::new(State {
3519+
history: ConversationHistory::new(),
3520+
..Default::default()
3521+
}),
3522+
codex_linux_sandbox_exe: None,
3523+
user_shell: shell::Shell::Unknown,
3524+
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
3525+
};
3526+
(session, turn_context)
3527+
}
3528+
3529+
fn sample_rollout(
3530+
session: &Session,
3531+
turn_context: &TurnContext,
3532+
) -> (Vec<RolloutItem>, Vec<ResponseItem>) {
3533+
let mut rollout_items = Vec::new();
3534+
let mut live_history = ConversationHistory::new();
3535+
3536+
let initial_context = session.build_initial_context(turn_context);
3537+
for item in &initial_context {
3538+
rollout_items.push(RolloutItem::ResponseItem(item.clone()));
3539+
}
3540+
live_history.record_items(initial_context.iter());
3541+
3542+
let user1 = ResponseItem::Message {
3543+
id: None,
3544+
role: "user".to_string(),
3545+
content: vec![ContentItem::InputText {
3546+
text: "first user".to_string(),
3547+
}],
3548+
};
3549+
live_history.record_items(std::iter::once(&user1));
3550+
rollout_items.push(RolloutItem::ResponseItem(user1.clone()));
3551+
3552+
let assistant1 = ResponseItem::Message {
3553+
id: None,
3554+
role: "assistant".to_string(),
3555+
content: vec![ContentItem::OutputText {
3556+
text: "assistant reply one".to_string(),
3557+
}],
3558+
};
3559+
live_history.record_items(std::iter::once(&assistant1));
3560+
rollout_items.push(RolloutItem::ResponseItem(assistant1.clone()));
3561+
3562+
let summary1 = "summary one";
3563+
let snapshot1 = live_history.contents();
3564+
let user_messages1 = collect_user_messages(&snapshot1);
3565+
let rebuilt1 = build_compacted_history(
3566+
session.build_initial_context(turn_context),
3567+
&user_messages1,
3568+
summary1,
3569+
);
3570+
live_history.replace(rebuilt1);
3571+
rollout_items.push(RolloutItem::Compacted(CompactedItem {
3572+
message: summary1.to_string(),
3573+
}));
3574+
3575+
let user2 = ResponseItem::Message {
3576+
id: None,
3577+
role: "user".to_string(),
3578+
content: vec![ContentItem::InputText {
3579+
text: "second user".to_string(),
3580+
}],
3581+
};
3582+
live_history.record_items(std::iter::once(&user2));
3583+
rollout_items.push(RolloutItem::ResponseItem(user2.clone()));
3584+
3585+
let assistant2 = ResponseItem::Message {
3586+
id: None,
3587+
role: "assistant".to_string(),
3588+
content: vec![ContentItem::OutputText {
3589+
text: "assistant reply two".to_string(),
3590+
}],
3591+
};
3592+
live_history.record_items(std::iter::once(&assistant2));
3593+
rollout_items.push(RolloutItem::ResponseItem(assistant2.clone()));
3594+
3595+
let summary2 = "summary two";
3596+
let snapshot2 = live_history.contents();
3597+
let user_messages2 = collect_user_messages(&snapshot2);
3598+
let rebuilt2 = build_compacted_history(
3599+
session.build_initial_context(turn_context),
3600+
&user_messages2,
3601+
summary2,
3602+
);
3603+
live_history.replace(rebuilt2);
3604+
rollout_items.push(RolloutItem::Compacted(CompactedItem {
3605+
message: summary2.to_string(),
3606+
}));
3607+
3608+
let user3 = ResponseItem::Message {
3609+
id: None,
3610+
role: "user".to_string(),
3611+
content: vec![ContentItem::InputText {
3612+
text: "third user".to_string(),
3613+
}],
3614+
};
3615+
live_history.record_items(std::iter::once(&user3));
3616+
rollout_items.push(RolloutItem::ResponseItem(user3.clone()));
3617+
3618+
let assistant3 = ResponseItem::Message {
3619+
id: None,
3620+
role: "assistant".to_string(),
3621+
content: vec![ContentItem::OutputText {
3622+
text: "assistant reply three".to_string(),
3623+
}],
3624+
};
3625+
live_history.record_items(std::iter::once(&assistant3));
3626+
rollout_items.push(RolloutItem::ResponseItem(assistant3.clone()));
3627+
3628+
(rollout_items, live_history.contents())
3629+
}
33893630
}

codex-rs/core/src/codex/compact.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ async fn run_compact_task_inner(
176176
};
177177
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
178178
let user_messages = collect_user_messages(&history_snapshot);
179-
let new_history =
180-
build_compacted_history(&sess, turn_context.as_ref(), &user_messages, &summary_text);
179+
let initial_context = sess.build_initial_context(turn_context.as_ref());
180+
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
181181
{
182182
let mut state = sess.state.lock_unchecked();
183183
state.history.replace(new_history);
@@ -223,7 +223,7 @@ fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
223223
}
224224
}
225225

226-
fn collect_user_messages(items: &[ResponseItem]) -> Vec<String> {
226+
pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<String> {
227227
items
228228
.iter()
229229
.filter_map(|item| match item {
@@ -243,13 +243,12 @@ fn is_session_prefix_message(text: &str) -> bool {
243243
)
244244
}
245245

246-
fn build_compacted_history(
247-
sess: &Session,
248-
turn_context: &TurnContext,
246+
pub(crate) fn build_compacted_history(
247+
initial_context: Vec<ResponseItem>,
249248
user_messages: &[String],
250249
summary_text: &str,
251250
) -> Vec<ResponseItem> {
252-
let mut history = sess.build_initial_context(turn_context);
251+
let mut history = initial_context;
253252
let user_messages_text = if user_messages.is_empty() {
254253
"(none)".to_string()
255254
} else {

0 commit comments

Comments
 (0)