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
+1
View File
@@ -1027,6 +1027,7 @@ mod tests {
text: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
})
}
@@ -148,6 +148,12 @@
},
"type": "object"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentMessageDeltaNotification": {
"properties": {
"delta": {
@@ -4194,6 +4200,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -6568,6 +6568,12 @@
},
"type": "object"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentMessageDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -19799,6 +19805,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/v2/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -313,6 +313,12 @@
},
"type": "object"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentMessageDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -17551,6 +17557,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -650,6 +656,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -650,6 +656,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -796,6 +802,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -25,6 +25,12 @@
],
"type": "object"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1353,6 +1359,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1144,6 +1150,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1144,6 +1150,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1144,6 +1150,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -25,6 +25,12 @@
],
"type": "object"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1353,6 +1359,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1144,6 +1150,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -25,6 +25,12 @@
],
"type": "object"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1353,6 +1359,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1144,6 +1150,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"AgentPath": {
"type": "string"
},
@@ -1144,6 +1150,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -796,6 +802,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -796,6 +802,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -5,6 +5,12 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageDelivery": {
"enum": [
"async"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -796,6 +802,17 @@
},
{
"properties": {
"delivery": {
"anyOf": [
{
"$ref": "#/definitions/AgentMessageDelivery"
},
{
"type": "null"
}
],
"default": null
},
"id": {
"type": "string"
},
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AgentMessageDelivery = "async";
@@ -8,6 +8,7 @@ import type { ReasoningEffort } from "../ReasoningEffort";
import type { SleepItem } from "../SleepItem";
import type { WebSearchItem } from "../WebSearchItem";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AgentMessageDelivery } from "./AgentMessageDelivery";
import type { CollabAgentState } from "./CollabAgentState";
import type { CollabAgentTool } from "./CollabAgentTool";
import type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus";
@@ -27,7 +28,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus";
import type { SubAgentActivityKind } from "./SubAgentActivityKind";
import type { UserInput } from "./UserInput";
export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
* Trusted first-party plugin id when this command resolves to one plugin script.
*/
@@ -14,6 +14,7 @@ export type { AdditionalContextKind } from "./AdditionalContextKind";
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
export type { AgentMessageDelivery } from "./AgentMessageDelivery";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppBranding } from "./AppBranding";
@@ -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,
}
);
+1 -1
View File
@@ -1603,7 +1603,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:
- `userMessage` — `{id, clientId, content}` where `clientId` is the optional `clientUserMessageId` supplied to `turn/start` or `turn/steer`, and `content` is a list of user inputs (`text`, `image`, `localImage`, `audio`, or `localAudio`).
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
- `agentMessage` — `{id, text, phase, memoryCitation, delivery}` containing the accumulated agent reply. `delivery: "async"` identifies a user-visible message sent without ending the current turn; ordinary agent messages have `delivery: null`.
- `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental).
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
- `commandExecution` — `{id, pluginId?, scriptPath?, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `pluginId` is present only for commands attributed to a trusted first-party plugin, newly attributed items also include `scriptPath` as a safe `/`-separated path relative to the trusted plugin root, older history may omit `scriptPath`, and `status` is `inProgress`, `completed`, `failed`, or `declined`. Ordinary execution items and their replay expose `command` and `commandActions` as redacted display values, not executable commands.
@@ -2225,6 +2225,7 @@ mod tests {
message: "after rollback".to_string(),
phase: None,
memory_citation: None,
delivery: None,
})),
];
let stored_thread = StoredThread {
@@ -3634,6 +3635,7 @@ mod tests {
],
phase: None,
memory_citation: None,
delivery: None,
}),
started_at_ms: Some(0),
completed_at_ms: 0,
@@ -3651,6 +3653,7 @@ mod tests {
}],
phase: None,
memory_citation: None,
delivery: None,
}),
started_at_ms: Some(0),
completed_at_ms: 0,
+24
View File
@@ -67,15 +67,18 @@ use crate::transport::CHANNEL_CAPACITY;
use crate::transport::OutboundConnectionState;
use crate::transport::route_outgoing_envelope;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::AgentMessageDelivery;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
@@ -111,6 +114,13 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b
| ServerNotification::ThreadQueueChanged(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
delivery: Some(AgentMessageDelivery::Async),
..
},
..
})
)
}
@@ -1011,5 +1021,19 @@ mod tests {
},
)
));
assert!(server_notification_requires_delivery(
&ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
id: "item-1".to_string(),
text: "Still working".to_string(),
phase: None,
memory_citation: None,
delivery: Some(AgentMessageDelivery::Async),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
})
));
}
}
@@ -73,6 +73,7 @@ mod tests {
text: "kept".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
@@ -122,6 +123,7 @@ mod tests {
text: "kept".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}
);
assert_eq!(
@@ -177,6 +177,7 @@ mod tests {
message: "first answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
})),
RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent {
info: None,
@@ -2041,6 +2041,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
text: "<EXTERNAL SESSION IMPORTED>".into(),
phase: None,
memory_citation: None,
delivery: None,
})
);
@@ -537,6 +537,7 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> {
}],
phase: Some(MessagePhase::Commentary),
memory_citation: None,
delivery: None,
}),
),
paginated_completed_item(
@@ -549,6 +550,7 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> {
}],
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
}),
),
paginated_turn_completed("turn-1"),
@@ -1577,6 +1579,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
}],
phase: None,
memory_citation: None,
delivery: None,
}),
),
paginated_completed_item(
@@ -1628,6 +1631,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
],
items_view: TurnItemsView::Full,
@@ -1813,6 +1817,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
],
items_view: TurnItemsView::Summary,
@@ -2058,6 +2063,7 @@ fn append_agent_message(path: &Path, timestamp: &str, text: &str) -> anyhow::Res
message: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))?,
})
)?;
@@ -3303,6 +3303,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn()
message: "Still running".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))?,
})
.to_string(),
@@ -3389,6 +3390,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re
message: "Interrupted after usage".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))?,
})
.to_string(),
@@ -3697,6 +3699,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is
message: "Still running".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))?,
})
.to_string(),
+1
View File
@@ -171,6 +171,7 @@ fn parse_agent_message(
content,
phase,
memory_citation: None,
delivery: None,
}
}
+1
View File
@@ -2020,6 +2020,7 @@ async fn emit_agent_message_in_plan_mode(
content: Vec::new(),
phase: None,
memory_citation: None,
delivery: None,
})
});
sess.emit_turn_item_started(turn_context, &start_item).await;
@@ -2100,6 +2100,7 @@ fn completed_legacy_event_history_is_not_mid_turn() {
message: "done".to_string(),
phase: None,
memory_citation: None,
delivery: None,
})),
]);
+2
View File
@@ -65,6 +65,7 @@ async fn turn_timing_state_records_ttfm_independently_of_ttft() {
content: Vec::new(),
phase: None,
memory_citation: None,
delivery: None,
}))
.await
.is_some()
@@ -76,6 +77,7 @@ async fn turn_timing_state_records_ttfm_independently_of_ttft() {
content: Vec::new(),
phase: None,
memory_citation: None,
delivery: None,
}))
.await,
None
@@ -250,6 +250,7 @@ fn final_message_from_turn_items_uses_latest_agent_message() {
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
ThreadItem::Plan {
id: "plan-1".to_string(),
@@ -260,6 +261,7 @@ fn final_message_from_turn_items_uses_latest_agent_message() {
text: "second".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
]);
@@ -318,6 +320,7 @@ fn turn_completed_recovers_final_message_from_turn_items() {
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}],
status: TurnStatus::Completed,
error: None,
@@ -366,6 +369,7 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() {
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}],
status: TurnStatus::Completed,
error: None,
@@ -18,6 +18,7 @@ fn failed_turn_does_not_overwrite_output_last_message_file() {
text: "partial answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
+2
View File
@@ -363,6 +363,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
@@ -393,6 +394,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}])
);
assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None);
@@ -305,6 +305,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() {
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -926,6 +927,7 @@ fn agent_message_item_updates_final_message() {
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -961,6 +963,7 @@ fn agent_message_item_started_is_ignored() {
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -1311,6 +1314,7 @@ fn turn_completion_recovers_final_message_from_turn_items() {
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}],
status: TurnStatus::Completed,
error: None,
@@ -1438,6 +1442,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
text: "stale answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -1456,6 +1461,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
}],
status: TurnStatus::Completed,
error: None,
@@ -1488,6 +1494,7 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty()
text: "streamed answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -1534,6 +1541,7 @@ fn failed_turn_clears_stale_final_message() {
text: "partial answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
@@ -135,6 +135,7 @@ pub(super) fn rollout_items_from_messages(messages: Vec<ConversationMessage>) ->
message: message.text.clone(),
phase: None,
memory_citation: None,
delivery: None,
},
)));
items.push(RolloutItem::ResponseItem(response_item(message).into()));
@@ -156,6 +157,7 @@ fn external_session_imported_marker_item() -> RolloutItem {
message: EXTERNAL_SESSION_IMPORTED_MARKER.to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))
}
@@ -251,6 +253,7 @@ mod tests {
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
memory_citation: None,
delivery: None,
}
);
}
@@ -283,6 +286,7 @@ mod tests {
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
memory_citation: None,
delivery: None,
})
);
let last_turn_complete = imported
+11
View File
@@ -113,6 +113,14 @@ pub enum AgentMessageContent {
Text { text: String },
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum AgentMessageDelivery {
Async,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
/// Assistant-authored message payload used in turn-item streams.
///
@@ -132,6 +140,9 @@ pub struct AgentMessageItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub memory_citation: Option<MemoryCitation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub delivery: Option<AgentMessageDelivery>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
+1
View File
@@ -105,6 +105,7 @@ impl AgentMessageItem {
message: text.clone(),
phase: self.phase.clone(),
memory_citation: self.memory_citation.clone(),
delivery: self.delivery,
}),
})
.collect()
+4
View File
@@ -32,6 +32,7 @@ use crate::dynamic_tools::DynamicToolCallRequest;
use crate::dynamic_tools::DynamicToolResponse;
use crate::dynamic_tools::DynamicToolSpec;
use crate::error::Result as CodexResult;
use crate::items::AgentMessageDelivery;
use crate::items::TurnItem;
use crate::mcp::CallToolResult;
use crate::mcp::RequestId;
@@ -2336,6 +2337,9 @@ pub struct AgentMessageEvent {
pub phase: Option<MessagePhase>,
#[serde(default)]
pub memory_citation: Option<MemoryCitation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub delivery: Option<AgentMessageDelivery>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
+4
View File
@@ -65,6 +65,7 @@ fn agent_message_item(message: &str) -> RolloutItem {
message: message.to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))
}
@@ -644,6 +645,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<
message: "buffered-event".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
))])
.await?;
@@ -891,6 +893,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i
message: "buffered-before-persist".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
))])
.await?;
@@ -942,6 +945,7 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st
message: "queued-after-writer-error".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
))]);
+1
View File
@@ -1025,6 +1025,7 @@ mod tests {
message: "commentary".to_string(),
phase: Some(MessagePhase::Commentary),
memory_citation: None,
delivery: None,
})),
RolloutItem::ResponseItem(
ResponseItem::FunctionCallOutput {
@@ -94,6 +94,7 @@ pub(super) fn completed_item(
}],
phase: event.phase.clone(),
memory_citation: event.memory_citation.clone(),
delivery: event.delivery,
}),
None,
)),
@@ -156,6 +156,7 @@ fn agent_message(text: &str) -> RolloutItem {
message: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))
}
@@ -226,6 +226,7 @@ async fn split_homes_support_backfill_listing_and_paginated_history() {
}],
phase: None,
memory_citation: None,
delivery: None,
}),
),
turn_completed("turn-1"),
@@ -318,6 +319,7 @@ async fn paginated_live_append_materializes_turn_items_and_state() {
}],
phase: None,
memory_citation: None,
delivery: None,
}),
),
turn_completed("turn-1"),
@@ -2343,6 +2345,7 @@ fn agent_message(id: &str, phase: MessagePhase) -> TurnItem {
}],
phase: Some(phase),
memory_citation: None,
delivery: None,
})
}
@@ -37,6 +37,7 @@ fn agent_status_uses_bounded_buffered_activity() {
text: "Finished checking the focused TUI tests.".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
thread_id: "thread-child".to_string(),
turn_id: "turn-1".to_string(),
+2
View File
@@ -3497,6 +3497,7 @@ async fn active_thread_file_change_approval_recovers_buffered_changes() {
text: "streaming".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
}),
/*replay_kind*/ None,
@@ -7235,6 +7236,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() {
text: "done".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
],
status: TurnStatus::Completed,
@@ -366,6 +366,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() ->
}],
phase: None,
memory_citation: None,
delivery: None,
})
}));
items.extend([
@@ -554,6 +555,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> {
}],
phase: None,
memory_citation: None,
delivery: None,
}),
started_at_ms: None,
completed_at_ms: 0,
@@ -919,6 +921,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
}],
phase: None,
memory_citation: None,
delivery: None,
}),
started_at_ms: None,
completed_at_ms: 0,
+1
View File
@@ -3360,6 +3360,7 @@ mod tests {
text: "assistant reply".to_string(),
phase: None,
memory_citation: None,
delivery: None,
},
],
status: TurnStatus::Completed,
+3
View File
@@ -94,6 +94,8 @@ impl ChatWidget {
text,
phase,
memory_citation,
delivery,
..
} => {
self.on_agent_message_item_completed(
AgentMessageItem {
@@ -117,6 +119,7 @@ impl ChatWidget {
rollout_ids: citation.thread_ids,
}
}),
delivery,
},
&turn_id,
from_replay,
@@ -621,6 +621,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item(
text: "Yes. What do you need?".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
};
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
@@ -1236,6 +1237,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() {
.to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
}];
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
@@ -767,6 +767,7 @@ pub(super) fn replay_agent_message(
text: text.into(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
},
"turn-1".to_string(),
replay_kind,
@@ -919,6 +920,7 @@ pub(super) fn complete_assistant_message(
text: text.to_string(),
phase,
memory_citation: None,
delivery: None,
},
}),
/*replay_kind*/ None,
@@ -332,6 +332,7 @@ async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history()
text: "review result is retained".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
},
],
..app_server_turn(
@@ -869,6 +869,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
text: "Plan details".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
}],
status: AppServerTurnStatus::Completed,
error: None,
@@ -907,6 +908,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
text: "Plan details".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
}],
status: AppServerTurnStatus::Completed,
error: None,
+1
View File
@@ -6332,6 +6332,7 @@ session_picker_view = "dense"
text: String::from("hello from assistant"),
phase: None,
memory_citation: None,
delivery: None,
},
ThreadItem::Plan {
id: String::from("plan-1"),
@@ -41,6 +41,7 @@ fn rollout_agent_message(text: &str) -> RolloutItem {
message: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
}))
}
@@ -258,6 +259,7 @@ fn transcript_preview_reverse_scan_stops_before_older_items() {
text: String::from("one\ntwo\nthree\nfour\nfive\nsix"),
phase: None,
memory_citation: None,
delivery: None,
};
let mut lines = Vec::new();