mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
## Description
This PR adds canonical core `TurnItem` shapes for command execution,
dynamic tool calls, collab agent tool calls, and sub-agent activity, to
be stored in the rollout file soon.
It also teaches app-server protocol / `ThreadHistoryBuilder` how to
render those items, and adds the small legacy fanout helpers needed for
existing event-based consumers. No core producer or rollout persistence
behavior changes here, that will be done in a followup.
## Making ThreadHistoryBuilder stateless
This is the first PR in a stack to make `ThreadHistoryBuilder` stateless
enough that we can materialize app-server `ThreadItem`s from only a
given slice of `RolloutItem` history, without ever needing to replay the
whole thread from the beginning.
The persisted legacy `RolloutItem::EventMsg` records are mostly shaped
like live UI events, not like materialized `ThreadItem`s. They work if
we replay the full rollout in order, but they often do not contain
enough stable identity or complete item state to project an arbitrary
suffix on its own.
A few examples:
- `UserMessageEvent` and `AgentMessageEvent` have content, but
historically do not carry the persisted app-server item ID that should
become the SQLite primary key.
- `AgentReasoningEvent` and `AgentReasoningRawContentEvent` are
fragments. `ThreadHistoryBuilder` currently merges them into the last
reasoning item, which means a slice starting in the middle of reasoning
cannot know whether to append to an earlier item or create a new one.
- `WebSearchEndEvent`, `McpToolCallEndEvent`, collab end events, and
similar legacy events can often render a final-looking item, but they
usually rely on prior replay state to know which turn owns the item.
- Begin/end legacy events are partial views of one logical item. The
builder correlates them by `call_id` and mutates prior state to
synthesize the final `ThreadItem`.
That is the problem this direction fixes. A persisted canonical
lifecycle record looks much closer to the read model we actually want
later:
```rust
ItemCompletedEvent {
turn_id,
item: TurnItem { id, ...full snapshot... },
completed_at_ms,
}
```
Once rollout has explicit `turn_id`, stable `item.id`, and a canonical
completed item snapshot, the future SQLite projector can reduce only the
new rollout suffix and upsert the affected `thread_items` rows. It no
longer needs to synthesize `item-N`, infer item ownership from the
active turn, or replay earlier events just to reconstruct the current
item snapshot.
## What changed
- Added core `TurnItem` variants and item structs for command execution,
dynamic tool calls, collab agent tool calls, and sub-agent activity.
- Added conversions from those canonical items back into the legacy
event shapes where current consumers still need them.
- Added app-server v2 `ThreadItem` conversion for the new core item
variants.
- Taught `ThreadHistoryBuilder` and rollout persistence metrics to
recognize the new item variants.
## Follow-up
The next PR https://github.com/openai/codex/pull/30283 switches the live
core producers for these item families onto canonical `ItemStarted` /
`ItemCompleted` events.
831 lines
27 KiB
Rust
831 lines
27 KiB
Rust
use crate::AgentPath;
|
|
use crate::ThreadId;
|
|
use crate::dynamic_tools::DynamicToolCallOutputContentItem;
|
|
use crate::mcp::CallToolResult;
|
|
use crate::memory_citation::MemoryCitation;
|
|
use crate::models::ContentItem;
|
|
use crate::models::ImageDetail;
|
|
use crate::models::MessagePhase;
|
|
use crate::models::ResponseItem;
|
|
use crate::models::WebSearchAction;
|
|
use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use crate::parse_command::ParsedCommand;
|
|
use crate::protocol::AgentMessageEvent;
|
|
use crate::protocol::AgentReasoningEvent;
|
|
use crate::protocol::AgentReasoningRawContentEvent;
|
|
use crate::protocol::AgentStatus;
|
|
use crate::protocol::CollabAgentRef;
|
|
use crate::protocol::ContextCompactedEvent;
|
|
use crate::protocol::EventMsg;
|
|
use crate::protocol::ExecCommandSource;
|
|
use crate::protocol::FileChange;
|
|
use crate::protocol::ImageGenerationEndEvent;
|
|
use crate::protocol::McpInvocation;
|
|
use crate::protocol::McpToolCallBeginEvent;
|
|
use crate::protocol::McpToolCallEndEvent;
|
|
use crate::protocol::PatchApplyBeginEvent;
|
|
use crate::protocol::PatchApplyEndEvent;
|
|
use crate::protocol::PatchApplyStatus;
|
|
use crate::protocol::SubAgentActivityKind;
|
|
use crate::protocol::UserMessageEvent;
|
|
use crate::protocol::ViewImageToolCallEvent;
|
|
use crate::protocol::WebSearchEndEvent;
|
|
use crate::user_input::ByteRange;
|
|
use crate::user_input::TextElement;
|
|
use crate::user_input::UserInput;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use quick_xml::de::from_str as from_xml_str;
|
|
use quick_xml::se::to_string as to_xml_string;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use ts_rs::TS;
|
|
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
#[serde(tag = "type")]
|
|
#[ts(tag = "type")]
|
|
pub enum TurnItem {
|
|
UserMessage(UserMessageItem),
|
|
HookPrompt(HookPromptItem),
|
|
AgentMessage(AgentMessageItem),
|
|
Plan(PlanItem),
|
|
Reasoning(ReasoningItem),
|
|
CommandExecution(CommandExecutionItem),
|
|
DynamicToolCall(DynamicToolCallItem),
|
|
CollabAgentToolCall(CollabAgentToolCallItem),
|
|
SubAgentActivity(SubAgentActivityItem),
|
|
WebSearch(WebSearchItem),
|
|
ImageView(ImageViewItem),
|
|
Sleep(SleepItem),
|
|
ImageGeneration(ImageGenerationItem),
|
|
FileChange(FileChangeItem),
|
|
McpToolCall(McpToolCallItem),
|
|
ContextCompaction(ContextCompactionItem),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct UserMessageItem {
|
|
pub id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub client_id: Option<String>,
|
|
pub content: Vec<UserInput>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
pub struct HookPromptItem {
|
|
pub id: String,
|
|
pub fragments: Vec<HookPromptFragment>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct HookPromptFragment {
|
|
pub text: String,
|
|
pub hook_run_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename = "hook_prompt")]
|
|
struct HookPromptXml {
|
|
#[serde(rename = "@hook_run_id")]
|
|
hook_run_id: String,
|
|
#[serde(rename = "$text")]
|
|
text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
#[serde(tag = "type")]
|
|
#[ts(tag = "type")]
|
|
pub enum AgentMessageContent {
|
|
Text { text: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
/// Assistant-authored message payload used in turn-item streams.
|
|
///
|
|
/// `phase` is optional because not all providers/models emit it. Consumers
|
|
/// should use it when present, but retain legacy completion semantics when it
|
|
/// is `None`.
|
|
pub struct AgentMessageItem {
|
|
pub id: String,
|
|
pub content: Vec<AgentMessageContent>,
|
|
/// Optional phase metadata carried through from `ResponseItem::Message`.
|
|
///
|
|
/// This is currently used by TUI rendering to distinguish mid-turn
|
|
/// commentary from a final answer and avoid status-indicator jitter.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub phase: Option<MessagePhase>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub memory_citation: Option<MemoryCitation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct PlanItem {
|
|
pub id: String,
|
|
pub text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct ReasoningItem {
|
|
pub id: String,
|
|
pub summary_text: Vec<String>,
|
|
#[serde(default)]
|
|
pub raw_content: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CommandExecutionStatus {
|
|
InProgress,
|
|
Completed,
|
|
Failed,
|
|
Declined,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct CommandExecutionItem {
|
|
pub id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub process_id: Option<String>,
|
|
pub command: Vec<String>,
|
|
pub cwd: PathUri,
|
|
pub parsed_cmd: Vec<ParsedCommand>,
|
|
pub source: ExecCommandSource,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub interaction_input: Option<String>,
|
|
pub status: CommandExecutionStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub stdout: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub stderr: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub aggregated_output: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub exit_code: Option<i32>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(type = "string", optional)]
|
|
pub duration: Option<Duration>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub formatted_output: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DynamicToolCallStatus {
|
|
InProgress,
|
|
Completed,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct DynamicToolCallItem {
|
|
pub id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub namespace: Option<String>,
|
|
pub tool: String,
|
|
pub arguments: serde_json::Value,
|
|
pub status: DynamicToolCallStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub content_items: Option<Vec<DynamicToolCallOutputContentItem>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub success: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub error: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(type = "string", optional)]
|
|
pub duration: Option<Duration>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CollabAgentTool {
|
|
SpawnAgent,
|
|
SendInput,
|
|
ResumeAgent,
|
|
Wait,
|
|
CloseAgent,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CollabAgentToolCallStatus {
|
|
InProgress,
|
|
Completed,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct CollabAgentToolCallItem {
|
|
pub id: String,
|
|
pub tool: CollabAgentTool,
|
|
pub status: CollabAgentToolCallStatus,
|
|
pub sender_thread_id: ThreadId,
|
|
#[serde(default)]
|
|
pub receiver_thread_ids: Vec<ThreadId>,
|
|
#[serde(default)]
|
|
pub receiver_agents: Vec<CollabAgentRef>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub prompt: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub model: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub reasoning_effort: Option<ReasoningEffortConfig>,
|
|
#[serde(default)]
|
|
pub agents_states: HashMap<ThreadId, AgentStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
pub struct SubAgentActivityItem {
|
|
pub id: String,
|
|
pub kind: SubAgentActivityKind,
|
|
pub agent_thread_id: ThreadId,
|
|
pub agent_path: AgentPath,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct WebSearchItem {
|
|
pub id: String,
|
|
pub query: String,
|
|
pub action: WebSearchAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct ImageViewItem {
|
|
pub id: String,
|
|
/// Path resolved within the selected execution environment.
|
|
///
|
|
/// This core protocol type is not exposed directly in the app-server API.
|
|
/// App-server converts the path to `LegacyAppPathString` at its boundary.
|
|
pub path: PathUri,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
pub struct SleepItem {
|
|
pub id: String,
|
|
pub duration_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct ImageGenerationItem {
|
|
pub id: String,
|
|
pub status: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub revised_prompt: Option<String>,
|
|
pub result: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub saved_path: Option<AbsolutePathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct FileChangeItem {
|
|
pub id: String,
|
|
pub changes: HashMap<PathBuf, FileChange>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub status: Option<PatchApplyStatus>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub auto_approved: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub stdout: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub stderr: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct McpToolCallItem {
|
|
pub id: String,
|
|
pub server: String,
|
|
pub tool: String,
|
|
pub arguments: serde_json::Value,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub connector_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub mcp_app_resource_uri: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub link_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub app_name: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub template_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub action_name: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub plugin_id: Option<String>,
|
|
pub status: McpToolCallStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub result: Option<CallToolResult>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(optional)]
|
|
pub error: Option<McpToolCallError>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[ts(type = "string", optional)]
|
|
pub duration: Option<Duration>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub enum McpToolCallStatus {
|
|
InProgress,
|
|
Completed,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct McpToolCallError {
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct ContextCompactionItem {
|
|
pub id: String,
|
|
}
|
|
|
|
impl ContextCompactionItem {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
EventMsg::ContextCompacted(ContextCompactedEvent {})
|
|
}
|
|
}
|
|
|
|
impl Default for ContextCompactionItem {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl UserMessageItem {
|
|
pub fn new(content: &[UserInput]) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
client_id: None,
|
|
content: content.to_vec(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
// Legacy user-message events flatten only text inputs into `message` and
|
|
// rebase text element ranges onto that concatenated text.
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: self.client_id.clone(),
|
|
message: self.message(),
|
|
images: Some(self.image_urls()),
|
|
image_details: self.image_details(),
|
|
local_images: self.local_image_paths(),
|
|
local_image_details: self.local_image_details(),
|
|
text_elements: self.text_elements(),
|
|
})
|
|
}
|
|
|
|
pub fn message(&self) -> String {
|
|
self.content
|
|
.iter()
|
|
.map(|c| match c {
|
|
UserInput::Text { text, .. } => text.clone(),
|
|
_ => String::new(),
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join("")
|
|
}
|
|
|
|
pub fn text_elements(&self) -> Vec<TextElement> {
|
|
let mut out = Vec::new();
|
|
let mut offset = 0usize;
|
|
for input in &self.content {
|
|
if let UserInput::Text {
|
|
text,
|
|
text_elements,
|
|
..
|
|
} = input
|
|
{
|
|
// Text element ranges are relative to each text chunk; offset them so they align
|
|
// with the concatenated message returned by `message()`.
|
|
for elem in text_elements {
|
|
let byte_range = ByteRange {
|
|
start: offset + elem.byte_range.start,
|
|
end: offset + elem.byte_range.end,
|
|
};
|
|
out.push(TextElement::new(
|
|
byte_range,
|
|
elem.placeholder(text).map(str::to_string),
|
|
));
|
|
}
|
|
offset += text.len();
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn image_urls(&self) -> Vec<String> {
|
|
self.content
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
UserInput::Image { image_url, .. } => Some(image_url.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn image_details(&self) -> Vec<Option<ImageDetail>> {
|
|
trim_trailing_default_image_details(
|
|
self.content
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
UserInput::Image { detail, .. } => Some(*detail),
|
|
_ => None,
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
|
|
self.content
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
UserInput::LocalImage { path, .. } => Some(path.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn local_image_details(&self) -> Vec<Option<ImageDetail>> {
|
|
trim_trailing_default_image_details(
|
|
self.content
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
UserInput::LocalImage { detail, .. } => Some(*detail),
|
|
_ => None,
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn trim_trailing_default_image_details(
|
|
mut details: Vec<Option<ImageDetail>>,
|
|
) -> Vec<Option<ImageDetail>> {
|
|
while matches!(details.last(), Some(None)) {
|
|
details.pop();
|
|
}
|
|
details
|
|
}
|
|
|
|
impl HookPromptItem {
|
|
pub fn from_fragments(id: Option<&String>, fragments: Vec<HookPromptFragment>) -> Self {
|
|
Self {
|
|
id: id
|
|
.cloned()
|
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
|
fragments,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HookPromptFragment {
|
|
pub fn from_single_hook(text: impl Into<String>, hook_run_id: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
hook_run_id: hook_run_id.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<ResponseItem> {
|
|
let content = fragments
|
|
.iter()
|
|
.filter(|fragment| !fragment.hook_run_id.trim().is_empty())
|
|
.filter_map(|fragment| {
|
|
serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id)
|
|
.map(|text| ContentItem::InputText { text })
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if content.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(ResponseItem::Message {
|
|
id: Some(uuid::Uuid::new_v4().to_string()),
|
|
role: "user".to_string(),
|
|
content,
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
})
|
|
}
|
|
|
|
pub fn parse_hook_prompt_message(
|
|
id: Option<&String>,
|
|
content: &[ContentItem],
|
|
) -> Option<HookPromptItem> {
|
|
let fragments = content
|
|
.iter()
|
|
.map(|content_item| {
|
|
let ContentItem::InputText { text } = content_item else {
|
|
return None;
|
|
};
|
|
parse_hook_prompt_fragment(text)
|
|
})
|
|
.collect::<Option<Vec<_>>>()?;
|
|
|
|
if fragments.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(HookPromptItem::from_fragments(id, fragments))
|
|
}
|
|
|
|
pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
|
|
let trimmed = text.trim();
|
|
let HookPromptXml { text, hook_run_id } = from_xml_str::<HookPromptXml>(trimmed).ok()?;
|
|
if hook_run_id.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(HookPromptFragment { text, hook_run_id })
|
|
}
|
|
|
|
fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option<String> {
|
|
if hook_run_id.trim().is_empty() {
|
|
return None;
|
|
}
|
|
to_xml_string(&HookPromptXml {
|
|
text: text.to_string(),
|
|
hook_run_id: hook_run_id.to_string(),
|
|
})
|
|
.ok()
|
|
}
|
|
|
|
impl AgentMessageItem {
|
|
pub fn new(content: &[AgentMessageContent]) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
content: content.to_vec(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_events(&self) -> Vec<EventMsg> {
|
|
self.content
|
|
.iter()
|
|
.map(|c| match c {
|
|
AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: text.clone(),
|
|
phase: self.phase.clone(),
|
|
memory_citation: self.memory_citation.clone(),
|
|
}),
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl ReasoningItem {
|
|
pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
|
|
let mut events = Vec::new();
|
|
for summary in &self.summary_text {
|
|
events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
|
|
text: summary.clone(),
|
|
}));
|
|
}
|
|
|
|
if show_raw_agent_reasoning {
|
|
for entry in &self.raw_content {
|
|
events.push(EventMsg::AgentReasoningRawContent(
|
|
AgentReasoningRawContentEvent {
|
|
text: entry.clone(),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
events
|
|
}
|
|
}
|
|
|
|
impl WebSearchItem {
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
EventMsg::WebSearchEnd(WebSearchEndEvent {
|
|
call_id: self.id.clone(),
|
|
query: self.query.clone(),
|
|
action: self.action.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ImageGenerationItem {
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
|
|
call_id: self.id.clone(),
|
|
status: self.status.clone(),
|
|
revised_prompt: self.revised_prompt.clone(),
|
|
result: self.result.clone(),
|
|
saved_path: self.saved_path.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl FileChangeItem {
|
|
pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg {
|
|
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
|
call_id: self.id.clone(),
|
|
turn_id,
|
|
auto_approved: self.auto_approved.unwrap_or(false),
|
|
changes: self.changes.clone(),
|
|
})
|
|
}
|
|
|
|
pub fn as_legacy_end_event(&self, turn_id: String) -> Option<EventMsg> {
|
|
let status = self.status.clone()?;
|
|
Some(EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
|
call_id: self.id.clone(),
|
|
turn_id,
|
|
stdout: self.stdout.clone().unwrap_or_default(),
|
|
stderr: self.stderr.clone().unwrap_or_default(),
|
|
success: status == PatchApplyStatus::Completed,
|
|
changes: self.changes.clone(),
|
|
status,
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl McpToolCallItem {
|
|
pub fn as_legacy_begin_event(&self) -> EventMsg {
|
|
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
|
call_id: self.id.clone(),
|
|
invocation: McpInvocation {
|
|
server: self.server.clone(),
|
|
tool: self.tool.clone(),
|
|
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
|
|
},
|
|
connector_id: self.connector_id.clone(),
|
|
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
|
|
link_id: self.link_id.clone(),
|
|
app_name: self.app_name.clone(),
|
|
template_id: self.template_id.clone(),
|
|
action_name: self.action_name.clone(),
|
|
plugin_id: self.plugin_id.clone(),
|
|
})
|
|
}
|
|
|
|
pub fn as_legacy_end_event(&self) -> Option<EventMsg> {
|
|
let result = match (&self.result, &self.error) {
|
|
(Some(result), _) => Ok(result.clone()),
|
|
(None, Some(error)) => Err(error.message.clone()),
|
|
(None, None) => return None,
|
|
};
|
|
|
|
Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
|
call_id: self.id.clone(),
|
|
invocation: McpInvocation {
|
|
server: self.server.clone(),
|
|
tool: self.tool.clone(),
|
|
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
|
|
},
|
|
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
|
|
connector_id: self.connector_id.clone(),
|
|
link_id: self.link_id.clone(),
|
|
app_name: self.app_name.clone(),
|
|
template_id: self.template_id.clone(),
|
|
action_name: self.action_name.clone(),
|
|
plugin_id: self.plugin_id.clone(),
|
|
duration: self.duration?,
|
|
result,
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl TurnItem {
|
|
pub fn id(&self) -> String {
|
|
match self {
|
|
TurnItem::UserMessage(item) => item.id.clone(),
|
|
TurnItem::HookPrompt(item) => item.id.clone(),
|
|
TurnItem::AgentMessage(item) => item.id.clone(),
|
|
TurnItem::Plan(item) => item.id.clone(),
|
|
TurnItem::Reasoning(item) => item.id.clone(),
|
|
TurnItem::CommandExecution(item) => item.id.clone(),
|
|
TurnItem::DynamicToolCall(item) => item.id.clone(),
|
|
TurnItem::CollabAgentToolCall(item) => item.id.clone(),
|
|
TurnItem::SubAgentActivity(item) => item.id.clone(),
|
|
TurnItem::WebSearch(item) => item.id.clone(),
|
|
TurnItem::ImageView(item) => item.id.clone(),
|
|
TurnItem::Sleep(item) => item.id.clone(),
|
|
TurnItem::ImageGeneration(item) => item.id.clone(),
|
|
TurnItem::FileChange(item) => item.id.clone(),
|
|
TurnItem::McpToolCall(item) => item.id.clone(),
|
|
TurnItem::ContextCompaction(item) => item.id.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
|
|
match self {
|
|
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
|
|
TurnItem::HookPrompt(_) => Vec::new(),
|
|
TurnItem::AgentMessage(item) => item.as_legacy_events(),
|
|
TurnItem::Plan(_) => Vec::new(),
|
|
TurnItem::CommandExecution(_)
|
|
| TurnItem::DynamicToolCall(_)
|
|
| TurnItem::CollabAgentToolCall(_) => Vec::new(),
|
|
TurnItem::SubAgentActivity(_) => Vec::new(),
|
|
TurnItem::WebSearch(item) => vec![item.as_legacy_event()],
|
|
TurnItem::ImageView(item) => {
|
|
vec![EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
|
|
call_id: item.id.clone(),
|
|
path: item.path.clone(),
|
|
})]
|
|
}
|
|
TurnItem::Sleep(_) => Vec::new(),
|
|
TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()],
|
|
TurnItem::FileChange(item) => item
|
|
.as_legacy_end_event(String::new())
|
|
.into_iter()
|
|
.collect(),
|
|
TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(),
|
|
TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning),
|
|
TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn hook_prompt_roundtrips_multiple_fragments() {
|
|
let original = vec![
|
|
HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"),
|
|
HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"),
|
|
];
|
|
let message = build_hook_prompt_message(&original).expect("hook prompt");
|
|
|
|
let ResponseItem::Message { content, .. } = message else {
|
|
panic!("expected hook prompt message");
|
|
};
|
|
|
|
let parsed = parse_hook_prompt_message(/*id*/ None, &content).expect("parsed hook prompt");
|
|
assert_eq!(parsed.fragments, original);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_prompt_parses_legacy_single_hook_run_id() {
|
|
let parsed = parse_hook_prompt_fragment(
|
|
r#"<hook_prompt hook_run_id="hook-run-1">Retry with tests.</hook_prompt>"#,
|
|
)
|
|
.expect("legacy hook prompt");
|
|
|
|
assert_eq!(
|
|
parsed,
|
|
HookPromptFragment {
|
|
text: "Retry with tests.".to_string(),
|
|
hook_run_id: "hook-run-1".to_string(),
|
|
}
|
|
);
|
|
}
|
|
}
|