Skip to content

Commit d587d95

Browse files
Fix resume state restoration
Resume now preserves streamed assistant deltas in active thread snapshots so the latest response is not truncated after rejoin. Persist per-thread service_tier alongside model reasoning metadata so resumed sessions restore their own fast/standard mode instead of inheriting the current session settings. Co-authored-by: Open Codex <hff582580@gmail.com>
1 parent e367c82 commit d587d95

19 files changed

Lines changed: 408 additions & 31 deletions

File tree

codex-rs/app-server-protocol/src/protocol/thread_history.rs

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::protocol::v2::UserInput;
2323
use crate::protocol::v2::WebSearchAction;
2424
use codex_protocol::items::parse_hook_prompt_message;
2525
use codex_protocol::models::MessagePhase;
26+
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
2627
use codex_protocol::protocol::AgentReasoningEvent;
2728
use codex_protocol::protocol::AgentReasoningRawContentEvent;
2829
use codex_protocol::protocol::AgentStatus;
@@ -55,6 +56,7 @@ use codex_protocol::protocol::ViewImageToolCallEvent;
5556
use codex_protocol::protocol::WebSearchBeginEvent;
5657
use codex_protocol::protocol::WebSearchEndEvent;
5758
use std::collections::HashMap;
59+
use std::collections::HashSet;
5860
use tracing::warn;
5961
use uuid::Uuid;
6062

@@ -97,6 +99,7 @@ pub struct ThreadHistoryBuilder {
9799
next_item_index: i64,
98100
current_rollout_index: usize,
99101
next_rollout_index: usize,
102+
delta_agent_message_item_ids: HashSet<String>,
100103
}
101104

102105
impl Default for ThreadHistoryBuilder {
@@ -113,6 +116,7 @@ impl ThreadHistoryBuilder {
113116
next_item_index: 1,
114117
current_rollout_index: 0,
115118
next_rollout_index: 0,
119+
delta_agent_message_item_ids: HashSet::new(),
116120
}
117121
}
118122

@@ -171,6 +175,9 @@ impl ThreadHistoryBuilder {
171175
pub fn handle_event(&mut self, event: &EventMsg) {
172176
match event {
173177
EventMsg::UserMessage(payload) => self.handle_user_message(payload),
178+
EventMsg::AgentMessageContentDelta(payload) => {
179+
self.handle_agent_message_content_delta(payload)
180+
}
174181
EventMsg::AgentMessage(payload) => self.handle_agent_message(
175182
payload.message.clone(),
176183
payload.phase.clone(),
@@ -300,6 +307,28 @@ impl ThreadHistoryBuilder {
300307
return;
301308
}
302309

310+
let delta_backed_last_id = self.ensure_turn().items.last().and_then(|item| {
311+
if matches!(item, ThreadItem::AgentMessage { .. }) {
312+
Some(item.id().to_string())
313+
} else {
314+
None
315+
}
316+
});
317+
if let Some(id) = delta_backed_last_id
318+
&& self.delta_agent_message_item_ids.remove(&id)
319+
&& let Some(ThreadItem::AgentMessage {
320+
text: existing_text,
321+
phase: existing_phase,
322+
memory_citation: existing_memory_citation,
323+
..
324+
}) = self.ensure_turn().items.last_mut()
325+
{
326+
*existing_text = text;
327+
*existing_phase = phase;
328+
*existing_memory_citation = memory_citation;
329+
return;
330+
}
331+
303332
let id = self.next_item_id();
304333
self.ensure_turn().items.push(ThreadItem::AgentMessage {
305334
id,
@@ -309,6 +338,52 @@ impl ThreadHistoryBuilder {
309338
});
310339
}
311340

341+
fn handle_agent_message_content_delta(&mut self, payload: &AgentMessageContentDeltaEvent) {
342+
if payload.delta.is_empty() {
343+
return;
344+
}
345+
346+
self.delta_agent_message_item_ids
347+
.insert(payload.item_id.clone());
348+
349+
let append_delta = |items: &mut Vec<ThreadItem>| {
350+
if let Some(ThreadItem::AgentMessage { text, .. }) =
351+
items.iter_mut().find(|item| item.id() == payload.item_id)
352+
{
353+
text.push_str(&payload.delta);
354+
return;
355+
}
356+
357+
items.push(ThreadItem::AgentMessage {
358+
id: payload.item_id.clone(),
359+
text: payload.delta.clone(),
360+
phase: None,
361+
memory_citation: None,
362+
});
363+
};
364+
365+
if let Some(turn) = self.current_turn.as_mut()
366+
&& turn.id == payload.turn_id
367+
{
368+
append_delta(&mut turn.items);
369+
return;
370+
}
371+
372+
if let Some(turn) = self
373+
.turns
374+
.iter_mut()
375+
.find(|turn| turn.id == payload.turn_id)
376+
{
377+
append_delta(&mut turn.items);
378+
return;
379+
}
380+
381+
warn!(
382+
item_id = payload.item_id,
383+
"dropping agent message delta for unknown turn id `{}`", payload.turn_id
384+
);
385+
}
386+
312387
fn handle_agent_reasoning(&mut self, payload: &AgentReasoningEvent) {
313388
if payload.text.is_empty() {
314389
return;
@@ -1254,6 +1329,7 @@ mod tests {
12541329
use codex_protocol::models::MessagePhase as CoreMessagePhase;
12551330
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
12561331
use codex_protocol::parse_command::ParsedCommand;
1332+
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
12571333
use codex_protocol::protocol::AgentMessageEvent;
12581334
use codex_protocol::protocol::AgentReasoningEvent;
12591335
use codex_protocol::protocol::AgentReasoningRawContentEvent;
@@ -1401,7 +1477,7 @@ mod tests {
14011477
local_images: Vec::new(),
14021478
}),
14031479
EventMsg::ItemStarted(ItemStartedEvent {
1404-
thread_id,
1480+
thread_id: thread_id.clone(),
14051481
turn_id: turn_id.to_string(),
14061482
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
14071483
id: "user-item-id".to_string(),
@@ -1462,6 +1538,86 @@ mod tests {
14621538
);
14631539
}
14641540

1541+
#[test]
1542+
fn active_turn_snapshot_accumulates_agent_message_deltas() {
1543+
let thread_id = ThreadId::new().to_string();
1544+
let mut builder = ThreadHistoryBuilder::new();
1545+
1546+
builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent {
1547+
turn_id: "turn-1".to_string(),
1548+
started_at: None,
1549+
model_context_window: None,
1550+
collaboration_mode_kind: Default::default(),
1551+
}));
1552+
builder.handle_event(&EventMsg::AgentMessageContentDelta(
1553+
AgentMessageContentDeltaEvent {
1554+
thread_id: thread_id.clone(),
1555+
turn_id: "turn-1".to_string(),
1556+
item_id: "msg-1".to_string(),
1557+
delta: "partial ".to_string(),
1558+
},
1559+
));
1560+
builder.handle_event(&EventMsg::AgentMessageContentDelta(
1561+
AgentMessageContentDeltaEvent {
1562+
thread_id: thread_id.clone(),
1563+
turn_id: "turn-1".to_string(),
1564+
item_id: "msg-1".to_string(),
1565+
delta: "reply".to_string(),
1566+
},
1567+
));
1568+
1569+
let turn = builder
1570+
.active_turn_snapshot()
1571+
.expect("active turn snapshot");
1572+
assert_eq!(
1573+
turn.items,
1574+
vec![ThreadItem::AgentMessage {
1575+
id: "msg-1".to_string(),
1576+
text: "partial reply".to_string(),
1577+
phase: None,
1578+
memory_citation: None,
1579+
}]
1580+
);
1581+
}
1582+
1583+
#[test]
1584+
fn final_agent_message_replaces_delta_backed_message() {
1585+
let thread_id = ThreadId::new().to_string();
1586+
let mut builder = ThreadHistoryBuilder::new();
1587+
1588+
builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent {
1589+
turn_id: "turn-1".to_string(),
1590+
started_at: None,
1591+
model_context_window: None,
1592+
collaboration_mode_kind: Default::default(),
1593+
}));
1594+
builder.handle_event(&EventMsg::AgentMessageContentDelta(
1595+
AgentMessageContentDeltaEvent {
1596+
thread_id: thread_id.clone(),
1597+
turn_id: "turn-1".to_string(),
1598+
item_id: "msg-1".to_string(),
1599+
delta: "streamed prefix".to_string(),
1600+
},
1601+
));
1602+
builder.handle_event(&EventMsg::AgentMessage(AgentMessageEvent {
1603+
message: "Final reply".to_string(),
1604+
phase: Some(CoreMessagePhase::FinalAnswer),
1605+
memory_citation: None,
1606+
}));
1607+
1608+
let turns = builder.finish();
1609+
assert_eq!(turns.len(), 1);
1610+
assert_eq!(
1611+
turns[0].items,
1612+
vec![ThreadItem::AgentMessage {
1613+
id: "msg-1".to_string(),
1614+
text: "Final reply".to_string(),
1615+
phase: Some(MessagePhase::FinalAnswer),
1616+
memory_citation: None,
1617+
}]
1618+
);
1619+
}
1620+
14651621
#[test]
14661622
fn replays_image_generation_end_events_into_turn_history() {
14671623
let items = vec![

codex-rs/app-server/src/request_processors/thread_processor.rs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,22 @@ fn merge_persisted_resume_metadata(
152152
typesafe_overrides: &mut ConfigOverrides,
153153
persisted_metadata: &ThreadMetadata,
154154
) {
155-
if has_model_resume_override(request_overrides.as_ref(), typesafe_overrides) {
156-
return;
155+
if !has_model_resume_override(request_overrides.as_ref(), typesafe_overrides) {
156+
typesafe_overrides.model = persisted_metadata.model.clone();
157+
typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone());
158+
159+
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort {
160+
request_overrides.get_or_insert_with(HashMap::new).insert(
161+
"model_reasoning_effort".to_string(),
162+
serde_json::Value::String(reasoning_effort.to_string()),
163+
);
164+
}
157165
}
158166

159-
typesafe_overrides.model = persisted_metadata.model.clone();
160-
typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone());
161-
162-
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort {
163-
request_overrides.get_or_insert_with(HashMap::new).insert(
164-
"model_reasoning_effort".to_string(),
165-
serde_json::Value::String(reasoning_effort.to_string()),
166-
);
167+
if typesafe_overrides.service_tier.is_none()
168+
&& let Some(service_tier) = &persisted_metadata.service_tier
169+
{
170+
typesafe_overrides.service_tier = Some(service_tier.clone());
167171
}
168172
}
169173

@@ -196,7 +200,6 @@ fn has_model_resume_override(
196200
typesafe_overrides: &ConfigOverrides,
197201
) -> bool {
198202
typesafe_overrides.model.is_some()
199-
|| typesafe_overrides.model_provider.is_some()
200203
|| request_overrides.is_some_and(|overrides| overrides.contains_key("model"))
201204
|| request_overrides
202205
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))

0 commit comments

Comments
 (0)