Add async delivery metadata to agent messages (#39312)

## What changed

- Add an optional `delivery` field to agent message events and app-server
  `agentMessage` items, with `"async"` identifying a user-visible message sent
  without ending the current turn.
- Preserve the marker through legacy event conversion, thread history
  materialization, replay, and generated JSON and TypeScript schemas.
- Require delivery of async agent message completion notifications on the
  in-process app-server transport.

## Testing

- Cover preservation of async delivery metadata in thread history.
- Cover delivery-required classification for async agent messages.

GitOrigin-RevId: f92ba25de1293ee271404badfdf0d117d6530329
This commit is contained in:
rka-oai
2026-08-18 23:05:52 +00:00
committed by copyberry
parent 7d9990fa30
commit fb356f3d2c
64 changed files with 467 additions and 21 deletions
@@ -28,7 +28,7 @@ use crate::protocol::v2::WebSearchItem;
use crate::protocol::v2::web_search_action_from_core;
use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::AgentStatus;
@@ -331,11 +331,7 @@ impl ThreadHistoryBuilder {
pub fn handle_event(&mut self, event: &EventMsg) {
match event {
EventMsg::UserMessage(payload) => self.handle_user_message(payload),
EventMsg::AgentMessage(payload) => self.handle_agent_message(
payload.message.clone(),
payload.phase.clone(),
payload.memory_citation.clone().map(Into::into),
),
EventMsg::AgentMessage(payload) => self.handle_agent_message(payload),
EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload),
EventMsg::AgentReasoningRawContent(payload) => {
self.handle_agent_reasoning_raw_content(payload)
@@ -491,22 +487,18 @@ impl ThreadHistoryBuilder {
});
}
fn handle_agent_message(
&mut self,
text: String,
phase: Option<MessagePhase>,
memory_citation: Option<crate::protocol::v2::MemoryCitation>,
) {
if text.is_empty() {
fn handle_agent_message(&mut self, payload: &AgentMessageEvent) {
if payload.message.is_empty() {
return;
}
let id = self.next_item_id();
self.push_item_in_current_turn(ThreadItem::AgentMessage {
id,
text,
phase,
memory_citation,
text: payload.message.clone(),
phase: payload.phase.clone(),
memory_citation: payload.memory_citation.clone().map(Into::into),
delivery: payload.delivery,
});
}
@@ -1628,6 +1620,7 @@ impl From<&PendingTurn> for Turn {
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::v2::AgentMessageDelivery;
use crate::protocol::v2::CommandExecutionSource;
use codex_extension_items::ExtensionItem as CoreExtensionItem;
use codex_extension_items::sleep::SleepItem as CoreSleepItem;
@@ -1646,7 +1639,6 @@ mod tests {
use codex_protocol::models::MessagePhase as CoreMessagePhase;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
@@ -1693,6 +1685,7 @@ mod tests {
message: "Hi there".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::AgentReasoning(AgentReasoningEvent {
text: "thinking".into(),
@@ -1712,6 +1705,7 @@ mod tests {
message: "Reply two".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
];
@@ -1750,6 +1744,7 @@ mod tests {
text: "Hi there".into(),
phase: None,
memory_citation: None,
delivery: None,
}
);
assert_eq!(
@@ -1783,6 +1778,7 @@ mod tests {
text: "Reply two".into(),
phase: None,
memory_citation: None,
delivery: None,
}
);
}
@@ -2330,11 +2326,12 @@ mod tests {
}
#[test]
fn preserves_agent_message_phase_in_history() {
fn preserves_agent_message_phase_and_delivery_in_history() {
let events = vec![EventMsg::AgentMessage(AgentMessageEvent {
message: "Final reply".into(),
phase: Some(CoreMessagePhase::FinalAnswer),
memory_citation: None,
delivery: Some(AgentMessageDelivery::Async),
})];
let items = events
@@ -2348,8 +2345,9 @@ mod tests {
ThreadItem::AgentMessage {
id: "item-1".into(),
text: "Final reply".into(),
phase: Some(MessagePhase::FinalAnswer),
phase: Some(CoreMessagePhase::FinalAnswer),
memory_citation: None,
delivery: Some(AgentMessageDelivery::Async),
}
);
}
@@ -2458,6 +2456,7 @@ mod tests {
message: "interlude".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::AgentReasoning(AgentReasoningEvent {
text: "second summary".into(),
@@ -2506,6 +2505,7 @@ mod tests {
message: "Working...".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".into()),
@@ -2526,6 +2526,7 @@ mod tests {
message: "Second attempt complete.".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
];
@@ -2557,6 +2558,7 @@ mod tests {
text: "Working...".into(),
phase: None,
memory_citation: None,
delivery: None,
}
);
@@ -2581,6 +2583,7 @@ mod tests {
text: "Second attempt complete.".into(),
phase: None,
memory_citation: None,
delivery: None,
}
);
}
@@ -2600,6 +2603,7 @@ mod tests {
message: "A1".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
@@ -2613,6 +2617,7 @@ mod tests {
message: "A2".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
EventMsg::UserMessage(UserMessageEvent {
@@ -2627,6 +2632,7 @@ mod tests {
message: "A3".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
];
@@ -2657,6 +2663,7 @@ mod tests {
text: "A1".into(),
phase: None,
memory_citation: None,
delivery: None,
},
]
);
@@ -2676,6 +2683,7 @@ mod tests {
text: "A3".into(),
phase: None,
memory_citation: None,
delivery: None,
},
]
);
@@ -2696,6 +2704,7 @@ mod tests {
message: "A1".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
@@ -2709,6 +2718,7 @@ mod tests {
message: "A2".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }),
];
@@ -3741,6 +3751,7 @@ mod tests {
message: "still in b".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-b".into(),
@@ -3917,6 +3928,7 @@ mod tests {
message: "still in b".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
];
@@ -4180,6 +4192,7 @@ mod tests {
message: "done".into(),
phase: None,
memory_citation: None,
delivery: None,
}),
EventMsg::Error(ErrorEvent {
message: "rollback failed".into(),
@@ -122,6 +122,7 @@ fn projects_completed_canonical_turn_items() {
}],
phase: None,
memory_citation: None,
delivery: None,
});
let user_changes = project(item_completed(thread_id, "turn-1", user_item.clone()));
@@ -24,6 +24,7 @@ use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmen
use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource;
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
pub use codex_protocol::items::AgentMessageDelivery;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::CollabAgentToolCallStatus as CoreCollabAgentToolCallStatus;
use codex_protocol::items::CommandExecutionStatus as CoreCommandExecutionStatus;
@@ -250,6 +251,8 @@ pub enum ThreadItem {
phase: Option<MessagePhase>,
#[serde(default)]
memory_citation: Option<MemoryCitation>,
#[serde(default)]
delivery: Option<AgentMessageDelivery>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -833,6 +836,7 @@ impl From<CoreTurnItem> for ThreadItem {
text,
phase: agent.phase,
memory_citation: agent.memory_citation.map(Into::into),
delivery: agent.delivery,
}
}
CoreTurnItem::Plan(plan) => ThreadItem::Plan {
@@ -2942,6 +2942,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
],
phase: None,
memory_citation: None,
delivery: None,
});
assert_eq!(
@@ -2951,6 +2952,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
text: "Hello world".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}
);
@@ -2969,6 +2971,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
}],
rollout_ids: vec!["rollout-1".to_string()],
}),
delivery: None,
});
assert_eq!(
@@ -2986,6 +2989,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
}],
thread_ids: vec!["rollout-1".to_string()],
}),
delivery: None,
}
);