mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
feat(rollout): persist TurnItems for paginated thread rollouts (#30188)
## Description This PR makes new threads with `history_mode = "paginated"` persist `ItemCompleted(item: <turn_item>)` in their rollout JSONL file. Legacy threads keep persisting the existing legacy events. Because the format is selected per thread, a rollout is either legacy or paginated; we do not need to support mixed rollouts containing both representations. This PR depends on [#31473](https://github.com/openai/codex/pull/31473). ## Why Paginated thread history needs stable turn/item IDs and completed item snapshots so the later SQLite projector can materialize appended rollout JSONL without rebuilding the whole thread. Keeping the legacy persistence policy unchanged avoids changing historical rollouts or the readers that still consume them. ## What changed - Made rollout filtering history-mode aware. Paginated threads keep completed canonical `ItemCompleted` events and drop their redundant legacy projections; legacy threads keep the existing event set. - Made forks inherit the source thread history mode, so copied legacy history is never filtered as paginated. - Made paginated threads assign IDs to locally-created response items even when `Feature::ItemIds` is off, and reject streamed output items that arrive without server IDs. - Updated legacy turn replay, rollout list/search, and SQLite metadata extraction to understand completed canonical user-message items.
This commit is contained in:
1 parent
5892c7b69d
commit
2342b2c2a6
26 files changed
+497
-220
No files matched your search
@@ -438,8 +438,8 @@ use codex_protocol::protocol::SessionConfiguredEvent;
|
|||||||
use codex_protocol::protocol::SessionMetaLine;
|
use codex_protocol::protocol::SessionMetaLine;
|
||||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
|
||||||
use codex_protocol::protocol::W3cTraceContext;
|
use codex_protocol::protocol::W3cTraceContext;
|
||||||
|
use codex_protocol::protocol::strip_user_message_prefix;
|
||||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||||
use codex_protocol::user_input::UserInput as CoreInputItem;
|
use codex_protocol::user_input::UserInput as CoreInputItem;
|
||||||
use codex_rmcp_client::perform_oauth_login_return_url_with_http_client;
|
use codex_rmcp_client::perform_oauth_login_return_url_with_http_client;
|
||||||
@@ -625,10 +625,10 @@ pub(crate) use self::thread_summary::summary_to_thread;
|
|||||||
pub(crate) use self::thread_summary::thread_settings_from_config_snapshot;
|
pub(crate) use self::thread_summary::thread_settings_from_config_snapshot;
|
||||||
pub(crate) use self::thread_summary::thread_settings_from_core_snapshot;
|
pub(crate) use self::thread_summary::thread_settings_from_core_snapshot;
|
||||||
|
|
||||||
pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
|
pub(crate) fn build_legacy_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
|
||||||
let mut builder = ThreadHistoryBuilder::new();
|
let mut builder = ThreadHistoryBuilder::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
if is_persisted_rollout_item(item) {
|
if is_persisted_rollout_item(item, codex_protocol::protocol::ThreadHistoryMode::Legacy) {
|
||||||
builder.handle_rollout_item(item);
|
builder.handle_rollout_item(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ impl ExternalAgentSessionImporter {
|
|||||||
memory_mode,
|
memory_mode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
rollout_items.retain(is_persisted_rollout_item);
|
rollout_items.retain(|item| is_persisted_rollout_item(item, ThreadHistoryMode::Legacy));
|
||||||
let title = title
|
let title = title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(codex_core::util::normalize_thread_name);
|
.and_then(codex_core::util::normalize_thread_name);
|
||||||
|
|||||||
@@ -738,7 +738,7 @@ pub(crate) fn populate_thread_turns_from_history(
|
|||||||
items: &[RolloutItem],
|
items: &[RolloutItem],
|
||||||
active_turn: Option<&Turn>,
|
active_turn: Option<&Turn>,
|
||||||
) {
|
) {
|
||||||
let mut turns = build_api_turns_from_rollout_items(items);
|
let mut turns = build_legacy_api_turns_from_rollout_items(items);
|
||||||
if let Some(active_turn) = active_turn {
|
if let Some(active_turn) = active_turn {
|
||||||
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
|
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2339,7 +2339,7 @@ impl ThreadRequestProcessor {
|
|||||||
let (mut thread, history) =
|
let (mut thread, history) =
|
||||||
thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd);
|
thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd);
|
||||||
if include_turns && let Some(history) = history {
|
if include_turns && let Some(history) = history {
|
||||||
thread.turns = build_api_turns_from_rollout_items(&history.items);
|
thread.turns = build_legacy_api_turns_from_rollout_items(&history.items);
|
||||||
}
|
}
|
||||||
Ok(Some(thread))
|
Ok(Some(thread))
|
||||||
}
|
}
|
||||||
@@ -2408,7 +2408,7 @@ impl ThreadRequestProcessor {
|
|||||||
.load_history(/*include_archived*/ true)
|
.load_history(/*include_archived*/ true)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| thread_read_history_load_error(thread_id, err))?;
|
.map_err(|err| thread_read_history_load_error(thread_id, err))?;
|
||||||
thread.turns = build_api_turns_from_rollout_items(&history.items);
|
thread.turns = build_legacy_api_turns_from_rollout_items(&history.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -4107,7 +4107,7 @@ fn reconstruct_thread_turns_for_turns_list(
|
|||||||
|| active_turn
|
|| active_turn
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress));
|
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress));
|
||||||
let mut turns = build_api_turns_from_rollout_items(items);
|
let mut turns = build_legacy_api_turns_from_rollout_items(items);
|
||||||
normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn);
|
normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn);
|
||||||
if let Some(active_turn) = active_turn {
|
if let Some(active_turn) = active_turn {
|
||||||
merge_turn_history_with_active_turn(&mut turns, active_turn);
|
merge_turn_history_with_active_turn(&mut turns, active_turn);
|
||||||
@@ -4480,10 +4480,7 @@ fn preview_from_rollout_items(items: &[RolloutItem]) -> String {
|
|||||||
},
|
},
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.map(|preview| match preview.find(USER_MESSAGE_BEGIN) {
|
.map(|preview| strip_user_message_prefix(preview.as_str()).to_string())
|
||||||
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string(),
|
|
||||||
None => preview,
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,10 +97,7 @@ fn extract_conversation_summary(
|
|||||||
_ => None,
|
_ => None,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let preview = match preview.find(USER_MESSAGE_BEGIN) {
|
let preview = strip_user_message_prefix(preview.as_str());
|
||||||
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
|
||||||
None => preview.as_str(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let timestamp = if session_meta.timestamp.is_empty() {
|
let timestamp = if session_meta.timestamp.is_empty() {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@@ -1379,7 +1379,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
InitialHistory::Forked(mut rollout_items) => {
|
InitialHistory::Forked(mut rollout_items) => {
|
||||||
let turn_context = self.new_default_turn().await;
|
let turn_context = self.new_default_turn().await;
|
||||||
if turn_context.config.features.enabled(Feature::ItemIds) {
|
if turn_context.item_ids_enabled() {
|
||||||
for rollout_item in &mut rollout_items {
|
for rollout_item in &mut rollout_items {
|
||||||
if let RolloutItem::ResponseItem(response_item) = rollout_item {
|
if let RolloutItem::ResponseItem(response_item) = rollout_item {
|
||||||
Self::assign_missing_response_item_id(response_item);
|
Self::assign_missing_response_item_id(response_item);
|
||||||
@@ -1962,7 +1962,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send_event_raw_with_persistence(&self, event: Event, persist: bool) {
|
async fn send_event_raw_with_persistence(&self, event: Event, persist: bool) {
|
||||||
// Persist the event into rollout storage (the store filters as needed).
|
// Persist the event into rollout storage; the store applies its persistence policy.
|
||||||
if persist {
|
if persist {
|
||||||
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
|
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
|
||||||
self.persist_rollout_items(&rollout_items).await;
|
self.persist_rollout_items(&rollout_items).await;
|
||||||
@@ -2776,7 +2776,7 @@ impl Session {
|
|||||||
for item in items.to_mut() {
|
for item in items.to_mut() {
|
||||||
item.set_turn_id_if_missing(&turn_context.sub_id);
|
item.set_turn_id_if_missing(&turn_context.sub_id);
|
||||||
}
|
}
|
||||||
if turn_context.config.features.enabled(Feature::ItemIds) {
|
if turn_context.item_ids_enabled() {
|
||||||
Self::assign_missing_response_item_ids(items)
|
Self::assign_missing_response_item_ids(items)
|
||||||
} else {
|
} else {
|
||||||
items
|
items
|
||||||
@@ -3035,7 +3035,7 @@ impl Session {
|
|||||||
world_state_baseline: Option<Arc<WorldState>>,
|
world_state_baseline: Option<Arc<WorldState>>,
|
||||||
compacted_item: CompactedItem,
|
compacted_item: CompactedItem,
|
||||||
) {
|
) {
|
||||||
let items = if turn_context.config.features.enabled(Feature::ItemIds) {
|
let items = if turn_context.item_ids_enabled() {
|
||||||
Self::assign_missing_response_item_ids(Cow::Owned(items)).into_owned()
|
Self::assign_missing_response_item_ids(Cow::Owned(items)).into_owned()
|
||||||
} else {
|
} else {
|
||||||
items
|
items
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub(super) async fn spawn_review_thread(
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
reasoning_summary,
|
reasoning_summary,
|
||||||
session_source,
|
session_source,
|
||||||
|
history_mode: parent_turn_context.history_mode,
|
||||||
parent_thread_id: parent_turn_context.parent_thread_id,
|
parent_thread_id: parent_turn_context.parent_thread_id,
|
||||||
originator: parent_turn_context.originator.clone(),
|
originator: parent_turn_context.originator.clone(),
|
||||||
environments: parent_turn_context.environments.clone(),
|
environments: parent_turn_context.environments.clone(),
|
||||||
|
|||||||
@@ -634,7 +634,12 @@ impl Session {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
LiveThread::resume(Arc::clone(&thread_store), params).await?
|
LiveThread::resume(
|
||||||
|
Arc::clone(&thread_store),
|
||||||
|
session_configuration.history_mode,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(Some(live_thread))
|
Ok(Some(live_thread))
|
||||||
@@ -1110,7 +1115,11 @@ impl Session {
|
|||||||
config.features.enabled(Feature::EnableRequestCompression),
|
config.features.enabled(Feature::EnableRequestCompression),
|
||||||
config.features.enabled(Feature::RuntimeMetrics),
|
config.features.enabled(Feature::RuntimeMetrics),
|
||||||
Self::build_model_client_beta_features_header(config.as_ref()),
|
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||||
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
|
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds)
|
||||||
|
|| matches!(
|
||||||
|
session_configuration.history_mode,
|
||||||
|
ThreadHistoryMode::Paginated
|
||||||
|
),
|
||||||
/*concurrent_reasoning_summaries_enabled*/ config
|
/*concurrent_reasoning_summaries_enabled*/ config
|
||||||
.features
|
.features
|
||||||
.enabled(Feature::ConcurrentReasoningSummaries),
|
.enabled(Feature::ConcurrentReasoningSummaries),
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|||||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||||
use codex_protocol::protocol::SandboxPolicy;
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||||
@@ -257,6 +258,24 @@ fn assign_missing_response_item_ids_assigns_additional_tools_ids() {
|
|||||||
assert!(items[0].id().is_some_and(|id| id.starts_with("at_")));
|
assert!(items[0].id().is_some_and(|id| id.starts_with("at_")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paginated_turn_context_assigns_missing_response_item_ids_without_feature() {
|
||||||
|
let (session, mut turn_context) = make_session_and_context().await;
|
||||||
|
turn_context.history_mode = ThreadHistoryMode::Paginated;
|
||||||
|
let response_item = user_message("hello");
|
||||||
|
|
||||||
|
let items = session.prepare_conversation_items_for_history(
|
||||||
|
&turn_context,
|
||||||
|
std::slice::from_ref(&response_item),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
items[0]
|
||||||
|
.id()
|
||||||
|
.is_some_and(|item_id| item_id.starts_with("msg_"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn assistant_message(text: &str) -> ResponseItem {
|
fn assistant_message(text: &str) -> ResponseItem {
|
||||||
ResponseItem::Message {
|
ResponseItem::Message {
|
||||||
id: None,
|
id: None,
|
||||||
|
|||||||
@@ -1916,6 +1916,21 @@ async fn drain_in_flight(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assign_missing_streamed_response_item_id(
|
||||||
|
item: &mut ResponseItem,
|
||||||
|
active_item: Option<&TurnItem>,
|
||||||
|
) {
|
||||||
|
if item.id().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_item_id = active_item
|
||||||
|
.map(TurnItem::id)
|
||||||
|
.filter(|item_id| !item_id.is_empty());
|
||||||
|
item.set_id(active_item_id);
|
||||||
|
Session::assign_missing_response_item_id(item);
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[instrument(level = "trace",
|
#[instrument(level = "trace",
|
||||||
skip_all,
|
skip_all,
|
||||||
@@ -2030,7 +2045,10 @@ async fn try_run_sampling_request(
|
|||||||
|
|
||||||
match event {
|
match event {
|
||||||
ResponseEvent::Created => {}
|
ResponseEvent::Created => {}
|
||||||
ResponseEvent::OutputItemDone(item) => {
|
ResponseEvent::OutputItemDone(mut item) => {
|
||||||
|
if turn_context.item_ids_enabled() {
|
||||||
|
assign_missing_streamed_response_item_id(&mut item, active_item.as_ref());
|
||||||
|
}
|
||||||
if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
|
if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
|
||||||
&& let Ok(Some(event)) = consumer.finish()
|
&& let Ok(Some(event)) = consumer.finish()
|
||||||
{
|
{
|
||||||
@@ -2124,7 +2142,10 @@ async fn try_run_sampling_request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponseEvent::OutputItemAdded(item) => {
|
ResponseEvent::OutputItemAdded(mut item) => {
|
||||||
|
if turn_context.item_ids_enabled() {
|
||||||
|
assign_missing_streamed_response_item_id(&mut item, /*active_item*/ None);
|
||||||
|
}
|
||||||
if let ResponseItem::CustomToolCall {
|
if let ResponseItem::CustomToolCall {
|
||||||
call_id,
|
call_id,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use codex_protocol::ThreadId;
|
|||||||
use codex_protocol::models::AdditionalPermissionProfile;
|
use codex_protocol::models::AdditionalPermissionProfile;
|
||||||
use codex_protocol::openai_models::ModelInfo;
|
use codex_protocol::openai_models::ModelInfo;
|
||||||
use codex_protocol::protocol::MultiAgentVersion;
|
use codex_protocol::protocol::MultiAgentVersion;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||||
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
|
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
|
||||||
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
||||||
@@ -111,6 +112,7 @@ pub struct TurnContext {
|
|||||||
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
|
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
|
||||||
pub(crate) reasoning_summary: ReasoningSummaryConfig,
|
pub(crate) reasoning_summary: ReasoningSummaryConfig,
|
||||||
pub(crate) session_source: SessionSource,
|
pub(crate) session_source: SessionSource,
|
||||||
|
pub(crate) history_mode: ThreadHistoryMode,
|
||||||
pub(crate) parent_thread_id: Option<ThreadId>,
|
pub(crate) parent_thread_id: Option<ThreadId>,
|
||||||
pub(crate) originator: String,
|
pub(crate) originator: String,
|
||||||
pub(crate) environments: TurnEnvironmentSnapshot,
|
pub(crate) environments: TurnEnvironmentSnapshot,
|
||||||
@@ -149,6 +151,11 @@ enum TurnMultiAgentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TurnContext {
|
impl TurnContext {
|
||||||
|
pub(crate) fn item_ids_enabled(&self) -> bool {
|
||||||
|
self.config.features.enabled(Feature::ItemIds)
|
||||||
|
|| matches!(self.history_mode, ThreadHistoryMode::Paginated)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn permission_profile(&self) -> PermissionProfile {
|
pub(crate) fn permission_profile(&self) -> PermissionProfile {
|
||||||
self.permission_profile.clone()
|
self.permission_profile.clone()
|
||||||
}
|
}
|
||||||
@@ -265,6 +272,7 @@ impl TurnContext {
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
reasoning_summary: self.reasoning_summary,
|
reasoning_summary: self.reasoning_summary,
|
||||||
session_source: self.session_source.clone(),
|
session_source: self.session_source.clone(),
|
||||||
|
history_mode: self.history_mode,
|
||||||
parent_thread_id: self.parent_thread_id,
|
parent_thread_id: self.parent_thread_id,
|
||||||
originator: self.originator.clone(),
|
originator: self.originator.clone(),
|
||||||
environments: self.environments.clone(),
|
environments: self.environments.clone(),
|
||||||
@@ -544,6 +552,7 @@ impl Session {
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
reasoning_summary,
|
reasoning_summary,
|
||||||
session_source,
|
session_source,
|
||||||
|
history_mode: session_configuration.history_mode,
|
||||||
parent_thread_id: session_configuration.parent_thread_id,
|
parent_thread_id: session_configuration.parent_thread_id,
|
||||||
originator: session_configuration.originator.clone(),
|
originator: session_configuration.originator.clone(),
|
||||||
environments,
|
environments,
|
||||||
|
|||||||
@@ -276,6 +276,76 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn missing_streamed_reasoning_id_is_reused_for_completion() -> anyhow::Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let TestCodex { codex, .. } = test_codex()
|
||||||
|
.with_config(|config| {
|
||||||
|
let _ = config.features.enable(Feature::ItemIds);
|
||||||
|
})
|
||||||
|
.build_with_auto_env(&server)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut reasoning_added = ev_reasoning_item_added("unused", &[]);
|
||||||
|
reasoning_added["item"]
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("reasoning item")
|
||||||
|
.remove("id");
|
||||||
|
let mut reasoning_done = ev_reasoning_item("unused", &["Consider inputs"], &[]);
|
||||||
|
reasoning_done["item"]
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("reasoning item")
|
||||||
|
.remove("id");
|
||||||
|
let response_mock = mount_sse_once(
|
||||||
|
&server,
|
||||||
|
sse(vec![
|
||||||
|
ev_response_created("resp-1"),
|
||||||
|
reasoning_added,
|
||||||
|
reasoning_done,
|
||||||
|
ev_completed("resp-1"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
codex
|
||||||
|
.submit(Op::UserInput {
|
||||||
|
items: vec![UserInput::Text {
|
||||||
|
text: "explain your reasoning".into(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}],
|
||||||
|
final_output_json_schema: None,
|
||||||
|
responsesapi_client_metadata: None,
|
||||||
|
additional_context: Default::default(),
|
||||||
|
thread_settings: Default::default(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let started_id = wait_for_event_match(&codex, |ev| match ev {
|
||||||
|
EventMsg::ItemStarted(ItemStartedEvent {
|
||||||
|
item: TurnItem::Reasoning(item),
|
||||||
|
..
|
||||||
|
}) => Some(item.id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let completed_id = wait_for_event_match(&codex, |ev| match ev {
|
||||||
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||||
|
item: TurnItem::Reasoning(item),
|
||||||
|
..
|
||||||
|
}) => Some(item.id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(started_id.starts_with("rs_"));
|
||||||
|
assert_eq!(started_id, completed_id);
|
||||||
|
response_mock.single_request();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn web_search_item_is_emitted() -> anyhow::Result<()> {
|
async fn web_search_item_is_emitted() -> anyhow::Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ impl ContextCompactionItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserMessageItem {
|
impl UserMessageItem {
|
||||||
pub fn as_legacy_event(&self) -> EventMsg {
|
pub fn as_legacy_user_message_event(&self) -> UserMessageEvent {
|
||||||
// Legacy user-message events flatten only text inputs into `message` and
|
// Legacy user-message events flatten only text inputs into `message` and
|
||||||
// rebase text element ranges onto that concatenated text.
|
// rebase text element ranges onto that concatenated text.
|
||||||
EventMsg::UserMessage(UserMessageEvent {
|
UserMessageEvent {
|
||||||
client_id: self.client_id.clone(),
|
client_id: self.client_id.clone(),
|
||||||
message: self.message(),
|
message: self.message(),
|
||||||
images: Some(self.image_urls()),
|
images: Some(self.image_urls()),
|
||||||
@@ -86,7 +86,11 @@ impl UserMessageItem {
|
|||||||
local_images: self.local_image_paths(),
|
local_images: self.local_image_paths(),
|
||||||
local_image_details: self.local_image_details(),
|
local_image_details: self.local_image_details(),
|
||||||
text_elements: self.text_elements(),
|
text_elements: self.text_elements(),
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_legacy_event(&self) -> EventMsg {
|
||||||
|
EventMsg::UserMessage(self.as_legacy_user_message_event())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ pub const CONTEXT_WINDOW_GUIDANCE_OPEN_TAG: &str = "<context_window_guidance>";
|
|||||||
pub const CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG: &str = "</context_window_guidance>";
|
pub const CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG: &str = "</context_window_guidance>";
|
||||||
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
|
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
|
||||||
|
|
||||||
|
/// Removes the model-context prefix from a user message before displaying it.
|
||||||
|
pub fn strip_user_message_prefix(text: &str) -> &str {
|
||||||
|
match text.find(USER_MESSAGE_BEGIN) {
|
||||||
|
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
||||||
|
None => text.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(anp): Replace `TurnEnvironmentSelection` with `PathUri` once path URIs carry environment
|
// TODO(anp): Replace `TurnEnvironmentSelection` with `PathUri` once path URIs carry environment
|
||||||
// identifiers.
|
// identifiers.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -2294,6 +2302,23 @@ pub struct UserMessageEvent {
|
|||||||
pub text_elements: Vec<crate::user_input::TextElement>,
|
pub text_elements: Vec<crate::user_input::TextElement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the user-facing preview text for a user message.
|
||||||
|
pub fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
|
||||||
|
let message = strip_user_message_prefix(user.message.as_str());
|
||||||
|
if !message.is_empty() {
|
||||||
|
return Some(message.to_string());
|
||||||
|
}
|
||||||
|
if user
|
||||||
|
.images
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|images| !images.is_empty())
|
||||||
|
|| !user.local_images.is_empty()
|
||||||
|
{
|
||||||
|
return Some("[Image]".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||||
pub struct AgentReasoningEvent {
|
pub struct AgentReasoningEvent {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -2591,11 +2616,11 @@ impl InitialHistory {
|
|||||||
|
|
||||||
pub fn get_history_mode(&self, default_history_mode: ThreadHistoryMode) -> ThreadHistoryMode {
|
pub fn get_history_mode(&self, default_history_mode: ThreadHistoryMode) -> ThreadHistoryMode {
|
||||||
match self {
|
match self {
|
||||||
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => {
|
InitialHistory::New | InitialHistory::Cleared => default_history_mode,
|
||||||
default_history_mode
|
// Forks copy their source rollout items as-is, so they must keep
|
||||||
}
|
// the source format instead of converting to the destination default.
|
||||||
InitialHistory::Resumed(_) => self
|
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => self
|
||||||
.get_resumed_session_meta()
|
.get_session_meta()
|
||||||
.map(|meta| meta.history_mode)
|
.map(|meta| meta.history_mode)
|
||||||
.unwrap_or(default_history_mode),
|
.unwrap_or(default_history_mode),
|
||||||
}
|
}
|
||||||
@@ -5782,13 +5807,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resumed_history_uses_persisted_history_mode() -> Result<()> {
|
fn copied_history_uses_persisted_history_mode() -> Result<()> {
|
||||||
let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?;
|
let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?;
|
||||||
let session_meta = RolloutItem::SessionMeta(SessionMetaLine {
|
let session_meta = RolloutItem::SessionMeta(SessionMetaLine {
|
||||||
meta: SessionMeta {
|
meta: SessionMeta {
|
||||||
session_id: thread_id.into(),
|
session_id: thread_id.into(),
|
||||||
id: thread_id,
|
id: thread_id,
|
||||||
history_mode: ThreadHistoryMode::Paginated,
|
history_mode: ThreadHistoryMode::Legacy,
|
||||||
..SessionMeta::default()
|
..SessionMeta::default()
|
||||||
},
|
},
|
||||||
git: None,
|
git: None,
|
||||||
@@ -5800,11 +5825,12 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
history.get_history_mode(ThreadHistoryMode::Legacy),
|
history.get_history_mode(ThreadHistoryMode::Paginated),
|
||||||
ThreadHistoryMode::Paginated
|
ThreadHistoryMode::Legacy
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
InitialHistory::Forked(vec![session_meta]).get_history_mode(ThreadHistoryMode::Legacy),
|
InitialHistory::Forked(vec![session_meta])
|
||||||
|
.get_history_mode(ThreadHistoryMode::Paginated),
|
||||||
ThreadHistoryMode::Legacy
|
ThreadHistoryMode::Legacy
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ use crate::protocol::EventMsg;
|
|||||||
use crate::state_db;
|
use crate::state_db;
|
||||||
use codex_file_search as file_search;
|
use codex_file_search as file_search;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::items::TurnItem;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::RolloutLine;
|
use codex_protocol::protocol::RolloutLine;
|
||||||
use codex_protocol::protocol::SessionMetaLine;
|
use codex_protocol::protocol::SessionMetaLine;
|
||||||
use codex_protocol::protocol::SessionSource;
|
use codex_protocol::protocol::SessionSource;
|
||||||
use codex_protocol::protocol::ThreadHistoryMode;
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
use codex_protocol::protocol::user_message_preview;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Returned page of thread (thread) summaries.
|
/// Returned page of thread (thread) summaries.
|
||||||
@@ -1184,12 +1185,19 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
|||||||
}
|
}
|
||||||
RolloutItem::EventMsg(ev) => {
|
RolloutItem::EventMsg(ev) => {
|
||||||
if let Some(preview) = event_msg_preview(&ev) {
|
if let Some(preview) = event_msg_preview(&ev) {
|
||||||
|
// Legacy rollouts persist UserMessage while paginated rollouts persist
|
||||||
|
// ItemCompleted(UserMessage), so summaries must recognize both formats.
|
||||||
|
let is_user_message = match &ev {
|
||||||
|
EventMsg::UserMessage(_) => true,
|
||||||
|
EventMsg::ItemCompleted(event) => {
|
||||||
|
matches!(event.item, TurnItem::UserMessage(_))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
if summary.preview.is_none() {
|
if summary.preview.is_none() {
|
||||||
summary.preview = Some(preview.clone());
|
summary.preview = Some(preview.clone());
|
||||||
}
|
}
|
||||||
if let EventMsg::UserMessage(_) = ev
|
if is_user_message && summary.first_user_message.is_none() {
|
||||||
&& summary.first_user_message.is_none()
|
|
||||||
{
|
|
||||||
summary.first_user_message = Some(preview);
|
summary.first_user_message = Some(preview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1250,30 +1258,15 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Va
|
|||||||
Ok(head)
|
Ok(head)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_user_message_prefix(text: &str) -> &str {
|
|
||||||
match text.find(USER_MESSAGE_BEGIN) {
|
|
||||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
|
||||||
None => text.trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn event_msg_preview(event: &EventMsg) -> Option<String> {
|
fn event_msg_preview(event: &EventMsg) -> Option<String> {
|
||||||
match event {
|
match event {
|
||||||
EventMsg::UserMessage(user) => {
|
EventMsg::UserMessage(user) => user_message_preview(user),
|
||||||
let message = strip_user_message_prefix(user.message.as_str());
|
EventMsg::ItemCompleted(event) => match &event.item {
|
||||||
if !message.is_empty() {
|
TurnItem::UserMessage(user) => {
|
||||||
return Some(message.to_string());
|
user_message_preview(&user.as_legacy_user_message_event())
|
||||||
}
|
}
|
||||||
if user
|
_ => None,
|
||||||
.images
|
},
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|images| !images.is_empty())
|
|
||||||
|| !user.local_images.is_empty()
|
|
||||||
{
|
|
||||||
return Some("[Image]".to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
EventMsg::ThreadGoalUpdated(event) => {
|
EventMsg::ThreadGoalUpdated(event) => {
|
||||||
let objective = event.goal.objective.trim();
|
let objective = event.goal.objective.trim();
|
||||||
(!objective.is_empty()).then(|| objective.to_string())
|
(!objective.is_empty()).then(|| objective.to_string())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use codex_protocol::items::TurnItem;
|
|||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
|
|
||||||
use crate::policy::is_persisted_rollout_item;
|
use crate::policy::is_persisted_rollout_item;
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ struct TurnMeasurementUpdate {
|
|||||||
/// Measures logical JSON sizes while applying the shared rollout persistence policy once.
|
/// Measures logical JSON sizes while applying the shared rollout persistence policy once.
|
||||||
pub fn measure_and_filter_rollout_items(
|
pub fn measure_and_filter_rollout_items(
|
||||||
items: &[RolloutItem],
|
items: &[RolloutItem],
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
) -> (Vec<RolloutItem>, RolloutPersistenceBatchMeasurement) {
|
) -> (Vec<RolloutItem>, RolloutPersistenceBatchMeasurement) {
|
||||||
let mut persisted = Vec::new();
|
let mut persisted = Vec::new();
|
||||||
let mut measurement = RolloutPersistenceBatchMeasurement {
|
let mut measurement = RolloutPersistenceBatchMeasurement {
|
||||||
@@ -103,7 +105,7 @@ pub fn measure_and_filter_rollout_items(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
let kept = is_persisted_rollout_item(item);
|
let kept = is_persisted_rollout_item(item, history_mode);
|
||||||
let decision = if kept {
|
let decision = if kept {
|
||||||
PersistenceDecision::Kept
|
PersistenceDecision::Kept
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::items::EnteredReviewModeItem;
|
||||||
|
use codex_protocol::items::ExitedReviewModeItem;
|
||||||
use codex_protocol::items::TurnItem;
|
use codex_protocol::items::TurnItem;
|
||||||
use codex_protocol::items::UserMessageItem;
|
use codex_protocol::items::UserMessageItem;
|
||||||
use codex_protocol::models::ContentItem;
|
use codex_protocol::models::ContentItem;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
|
use codex_protocol::protocol::EnteredReviewModeEvent;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
|
use codex_protocol::protocol::ExitedReviewModeEvent;
|
||||||
use codex_protocol::protocol::ItemCompletedEvent;
|
use codex_protocol::protocol::ItemCompletedEvent;
|
||||||
|
use codex_protocol::protocol::ReviewTarget;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::TurnAbortReason;
|
use codex_protocol::protocol::TurnAbortReason;
|
||||||
use codex_protocol::protocol::TurnAbortedEvent;
|
use codex_protocol::protocol::TurnAbortedEvent;
|
||||||
use codex_protocol::protocol::TurnCompleteEvent;
|
use codex_protocol::protocol::TurnCompleteEvent;
|
||||||
@@ -65,7 +71,7 @@ fn update_for_batch(
|
|||||||
state: &mut TurnMeasurementState,
|
state: &mut TurnMeasurementState,
|
||||||
items: &[RolloutItem],
|
items: &[RolloutItem],
|
||||||
) -> super::TurnMeasurementUpdate {
|
) -> super::TurnMeasurementUpdate {
|
||||||
let (_, measurement) = measure_and_filter_rollout_items(items);
|
let (_, measurement) = measure_and_filter_rollout_items(items, ThreadHistoryMode::Legacy);
|
||||||
update_turn_measurements(state, items, &measurement)
|
update_turn_measurements(state, items, &measurement)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +106,8 @@ fn mixed_batch_reports_exact_policy_counts_and_bytes() {
|
|||||||
let dropped = RolloutItem::ResponseItem(ResponseItem::Other);
|
let dropped = RolloutItem::ResponseItem(ResponseItem::Other);
|
||||||
let items = vec![kept.clone(), dropped.clone()];
|
let items = vec![kept.clone(), dropped.clone()];
|
||||||
|
|
||||||
let (persisted, measurement) = measure_and_filter_rollout_items(&items);
|
let (persisted, measurement) =
|
||||||
|
measure_and_filter_rollout_items(&items, ThreadHistoryMode::Legacy);
|
||||||
let kept_bytes = serde_json::to_vec(&kept)
|
let kept_bytes = serde_json::to_vec(&kept)
|
||||||
.expect("serialize kept item")
|
.expect("serialize kept item")
|
||||||
.len() as u64;
|
.len() as u64;
|
||||||
@@ -128,7 +135,8 @@ fn mixed_batch_reports_exact_policy_counts_and_bytes() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn retained_items_are_byte_identical() {
|
fn retained_items_are_byte_identical() {
|
||||||
let item = retained_message("a moderately sized payload");
|
let item = retained_message("a moderately sized payload");
|
||||||
let (persisted, measurement) = measure_and_filter_rollout_items(std::slice::from_ref(&item));
|
let (persisted, measurement) =
|
||||||
|
measure_and_filter_rollout_items(std::slice::from_ref(&item), ThreadHistoryMode::Legacy);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_vec(&persisted[0]).expect("serialize persisted item"),
|
serde_json::to_vec(&persisted[0]).expect("serialize persisted item"),
|
||||||
@@ -155,8 +163,10 @@ fn turn_measurements_span_batches_and_include_items_before_start() {
|
|||||||
retained_message("second response"),
|
retained_message("second response"),
|
||||||
turn_aborted("turn-2"),
|
turn_aborted("turn-2"),
|
||||||
];
|
];
|
||||||
let (_, first_expected) = measure_and_filter_rollout_items(&first_turn);
|
let (_, first_expected) =
|
||||||
let (_, second_expected) = measure_and_filter_rollout_items(&second_turn);
|
measure_and_filter_rollout_items(&first_turn, ThreadHistoryMode::Legacy);
|
||||||
|
let (_, second_expected) =
|
||||||
|
measure_and_filter_rollout_items(&second_turn, ThreadHistoryMode::Legacy);
|
||||||
let batches = [
|
let batches = [
|
||||||
first_turn[..1].to_vec(),
|
first_turn[..1].to_vec(),
|
||||||
first_turn[1..3].to_vec(),
|
first_turn[1..3].to_vec(),
|
||||||
@@ -217,7 +227,8 @@ fn invalid_turn_boundaries_reset_partial_measurements() {
|
|||||||
retained_message("retained turn"),
|
retained_message("retained turn"),
|
||||||
turn_complete("turn-2"),
|
turn_complete("turn-2"),
|
||||||
];
|
];
|
||||||
let (_, expected) = measure_and_filter_rollout_items(&replacement[2..]);
|
let (_, expected) =
|
||||||
|
measure_and_filter_rollout_items(&replacement[2..], ThreadHistoryMode::Legacy);
|
||||||
let update = update_for_batch(&mut state, &replacement);
|
let update = update_for_batch(&mut state, &replacement);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -235,7 +246,7 @@ fn invalid_turn_boundaries_reset_partial_measurements() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filtered_item_completion_includes_its_nested_item_type() {
|
fn item_completion_persistence_depends_on_history_mode() {
|
||||||
let item = RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
let item = RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||||
thread_id: ThreadId::default(),
|
thread_id: ThreadId::default(),
|
||||||
turn_id: "turn".to_string(),
|
turn_id: "turn".to_string(),
|
||||||
@@ -247,14 +258,87 @@ fn filtered_item_completion_includes_its_nested_item_type() {
|
|||||||
completed_at_ms: 0,
|
completed_at_ms: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let (_, measurement) = measure_and_filter_rollout_items(&[item]);
|
let (_, legacy_measurement) =
|
||||||
|
measure_and_filter_rollout_items(std::slice::from_ref(&item), ThreadHistoryMode::Legacy);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
measurement.items[0].rollout_item_type,
|
legacy_measurement.items[0].rollout_item_type,
|
||||||
"event.item_completed.user_message"
|
"event.item_completed.user_message"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
measurement.items[0].decision,
|
legacy_measurement.items[0].decision,
|
||||||
super::PersistenceDecision::Dropped
|
super::PersistenceDecision::Dropped
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let (persisted, paginated_measurement) =
|
||||||
|
measure_and_filter_rollout_items(std::slice::from_ref(&item), ThreadHistoryMode::Paginated);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(persisted).expect("serialize persisted items"),
|
||||||
|
serde_json::to_value([item]).expect("serialize expected items")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
paginated_measurement.items[0].decision,
|
||||||
|
super::PersistenceDecision::Kept
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn review_mode_persistence_depends_on_history_mode() {
|
||||||
|
let completed_items = vec![
|
||||||
|
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||||
|
thread_id: ThreadId::default(),
|
||||||
|
turn_id: "turn".to_string(),
|
||||||
|
item: TurnItem::EnteredReviewMode(EnteredReviewModeItem {
|
||||||
|
id: "entered-review".to_string(),
|
||||||
|
target: ReviewTarget::Custom {
|
||||||
|
instructions: "review this".to_string(),
|
||||||
|
},
|
||||||
|
user_facing_hint: "Review requested.".to_string(),
|
||||||
|
}),
|
||||||
|
completed_at_ms: 0,
|
||||||
|
})),
|
||||||
|
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||||
|
thread_id: ThreadId::default(),
|
||||||
|
turn_id: "turn".to_string(),
|
||||||
|
item: TurnItem::ExitedReviewMode(ExitedReviewModeItem {
|
||||||
|
id: "exited-review".to_string(),
|
||||||
|
review_output: None,
|
||||||
|
}),
|
||||||
|
completed_at_ms: 0,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
let legacy_events = vec![
|
||||||
|
RolloutItem::EventMsg(EventMsg::EnteredReviewMode(EnteredReviewModeEvent {
|
||||||
|
target: ReviewTarget::Custom {
|
||||||
|
instructions: "review this".to_string(),
|
||||||
|
},
|
||||||
|
user_facing_hint: Some("Review requested.".to_string()),
|
||||||
|
turn_id: Some("turn".to_string()),
|
||||||
|
item_id: Some("entered-review".to_string()),
|
||||||
|
})),
|
||||||
|
RolloutItem::EventMsg(EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
|
||||||
|
turn_id: Some("turn".to_string()),
|
||||||
|
item_id: Some("exited-review".to_string()),
|
||||||
|
review_output: None,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
let items = completed_items
|
||||||
|
.iter()
|
||||||
|
.chain(&legacy_events)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let (persisted_legacy, _) = measure_and_filter_rollout_items(&items, ThreadHistoryMode::Legacy);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(persisted_legacy).expect("serialize persisted items"),
|
||||||
|
serde_json::to_value(legacy_events).expect("serialize expected items")
|
||||||
|
);
|
||||||
|
|
||||||
|
let (persisted_paginated, _) =
|
||||||
|
measure_and_filter_rollout_items(&items, ThreadHistoryMode::Paginated);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(persisted_paginated).expect("serialize persisted items"),
|
||||||
|
serde_json::to_value(completed_items).expect("serialize expected items")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
use crate::protocol::EventMsg;
|
use crate::protocol::EventMsg;
|
||||||
use crate::protocol::RolloutItem;
|
use crate::protocol::RolloutItem;
|
||||||
|
use codex_protocol::items::TurnItem;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
|
|
||||||
/// Whether a rollout `item` should be persisted in rollout files.
|
/// Whether a rollout `item` should be persisted in rollout files.
|
||||||
pub fn is_persisted_rollout_item(item: &RolloutItem) -> bool {
|
pub fn is_persisted_rollout_item(item: &RolloutItem, history_mode: ThreadHistoryMode) -> bool {
|
||||||
match item {
|
match item {
|
||||||
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
|
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
|
||||||
RolloutItem::InterAgentCommunication(_)
|
RolloutItem::InterAgentCommunication(_)
|
||||||
| RolloutItem::InterAgentCommunicationMetadata { .. } => true,
|
| RolloutItem::InterAgentCommunicationMetadata { .. } => true,
|
||||||
RolloutItem::EventMsg(ev) => should_persist_event_msg(ev),
|
RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, history_mode),
|
||||||
// Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns).
|
// Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns).
|
||||||
RolloutItem::Compacted(_)
|
RolloutItem::Compacted(_)
|
||||||
| RolloutItem::TurnContext(_)
|
| RolloutItem::TurnContext(_)
|
||||||
@@ -17,11 +19,14 @@ pub fn is_persisted_rollout_item(item: &RolloutItem) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the canonical rollout items that should be persisted for a live append.
|
/// Return the rollout items that should be persisted for a live append.
|
||||||
pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec<RolloutItem> {
|
pub fn persisted_rollout_items(
|
||||||
|
items: &[RolloutItem],
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
|
) -> Vec<RolloutItem> {
|
||||||
let mut persisted = Vec::new();
|
let mut persisted = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
if is_persisted_rollout_item(item) {
|
if is_persisted_rollout_item(item, history_mode) {
|
||||||
persisted.push(item.clone());
|
persisted.push(item.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,37 +83,38 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool {
|
|||||||
|
|
||||||
/// Whether an `EventMsg` should be persisted in rollout files.
|
/// Whether an `EventMsg` should be persisted in rollout files.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
pub fn should_persist_event_msg(ev: &EventMsg, history_mode: ThreadHistoryMode) -> bool {
|
||||||
match ev {
|
match ev {
|
||||||
EventMsg::UserMessage(_)
|
EventMsg::ItemCompleted(event) => {
|
||||||
| EventMsg::AgentMessage(_)
|
// Paginated rollouts store TurnItems.
|
||||||
| EventMsg::AgentReasoning(_)
|
// Legacy rollouts keep only items with no raw ResponseItem or legacy equivalent.
|
||||||
| EventMsg::AgentReasoningRawContent(_)
|
matches!(history_mode, ThreadHistoryMode::Paginated)
|
||||||
| EventMsg::PatchApplyEnd(_)
|
|| matches!(event.item, TurnItem::Plan(_) | TurnItem::Sleep(_))
|
||||||
| EventMsg::TokenCount(_)
|
}
|
||||||
|
EventMsg::TokenCount(_)
|
||||||
| EventMsg::ThreadGoalUpdated(_)
|
| EventMsg::ThreadGoalUpdated(_)
|
||||||
| EventMsg::ContextCompacted(_)
|
|
||||||
| EventMsg::EnteredReviewMode(_)
|
|
||||||
| EventMsg::ExitedReviewMode(_)
|
|
||||||
| EventMsg::McpToolCallEnd(_)
|
|
||||||
| EventMsg::ThreadRolledBack(_)
|
| EventMsg::ThreadRolledBack(_)
|
||||||
| EventMsg::TurnAborted(_)
|
| EventMsg::TurnAborted(_)
|
||||||
| EventMsg::TurnStarted(_)
|
| EventMsg::TurnStarted(_)
|
||||||
| EventMsg::TurnComplete(_)
|
| EventMsg::TurnComplete(_)
|
||||||
| EventMsg::ThreadSettingsApplied(_)
|
| EventMsg::ThreadSettingsApplied(_) => true,
|
||||||
|
|
||||||
|
// Only persist these legacy events when the thread's history mode is Legacy.
|
||||||
|
// New, paginated rollouts persist ItemCompleted events with TurnItems.
|
||||||
|
EventMsg::UserMessage(_)
|
||||||
|
| EventMsg::AgentMessage(_)
|
||||||
|
| EventMsg::AgentReasoning(_)
|
||||||
|
| EventMsg::AgentReasoningRawContent(_)
|
||||||
|
| EventMsg::EnteredReviewMode(_)
|
||||||
|
| EventMsg::ExitedReviewMode(_)
|
||||||
|
| EventMsg::PatchApplyEnd(_)
|
||||||
|
| EventMsg::ContextCompacted(_)
|
||||||
|
| EventMsg::McpToolCallEnd(_)
|
||||||
| EventMsg::WebSearchEnd(_)
|
| EventMsg::WebSearchEnd(_)
|
||||||
| EventMsg::ImageGenerationEnd(_)
|
| EventMsg::ImageGenerationEnd(_)
|
||||||
| EventMsg::SubAgentActivity(_) => true,
|
| EventMsg::SubAgentActivity(_) => matches!(history_mode, ThreadHistoryMode::Legacy),
|
||||||
EventMsg::ItemCompleted(event) => {
|
|
||||||
// These items have no equivalent raw ResponseItem or legacy event,
|
// Transient, non-durable events.
|
||||||
// so persist their completion for replay without retaining every
|
|
||||||
// item lifecycle event.
|
|
||||||
matches!(
|
|
||||||
event.item,
|
|
||||||
codex_protocol::items::TurnItem::Plan(_)
|
|
||||||
| codex_protocol::items::TurnItem::Sleep(_)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
EventMsg::Error(_)
|
EventMsg::Error(_)
|
||||||
| EventMsg::GuardianAssessment(_)
|
| EventMsg::GuardianAssessment(_)
|
||||||
| EventMsg::ExecCommandEnd(_)
|
| EventMsg::ExecCommandEnd(_)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use codex_protocol::models::ResponseItem;
|
|||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::RolloutLine;
|
use codex_protocol::protocol::RolloutLine;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
use codex_protocol::protocol::strip_user_message_prefix;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use regex::RegexBuilder;
|
use regex::RegexBuilder;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
@@ -297,13 +297,6 @@ fn content_item_text(item: &ContentItem) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_user_message_prefix(text: &str) -> &str {
|
|
||||||
match text.find(USER_MESSAGE_BEGIN) {
|
|
||||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
|
||||||
None => text.trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn excerpt_around_match(text: &str, search_term: &Regex) -> Option<String> {
|
fn excerpt_around_match(text: &str, search_term: &Regex) -> Option<String> {
|
||||||
let normalized = normalize_preview_text(text);
|
let normalized = normalize_preview_text(text);
|
||||||
let matched = search_term.find(normalized.as_str())?;
|
let matched = search_term.find(normalized.as_str())?;
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use crate::model::ThreadMetadata;
|
use crate::model::ThreadMetadata;
|
||||||
|
use codex_protocol::items::TurnItem;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::SessionMetaLine;
|
use codex_protocol::protocol::SessionMetaLine;
|
||||||
use codex_protocol::protocol::TurnContextItem;
|
use codex_protocol::protocol::TurnContextItem;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
|
||||||
use codex_protocol::protocol::UserMessageEvent;
|
use codex_protocol::protocol::UserMessageEvent;
|
||||||
|
use codex_protocol::protocol::strip_user_message_prefix;
|
||||||
|
use codex_protocol::protocol::user_message_preview;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]";
|
|
||||||
|
|
||||||
/// Apply a rollout item to the metadata structure.
|
/// Apply a rollout item to the metadata structure.
|
||||||
pub fn apply_rollout_item(
|
pub fn apply_rollout_item(
|
||||||
metadata: &mut ThreadMetadata,
|
metadata: &mut ThreadMetadata,
|
||||||
@@ -39,6 +39,11 @@ pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool {
|
|||||||
RolloutItem::EventMsg(
|
RolloutItem::EventMsg(
|
||||||
EventMsg::TokenCount(_) | EventMsg::UserMessage(_) | EventMsg::ThreadGoalUpdated(_),
|
EventMsg::TokenCount(_) | EventMsg::UserMessage(_) | EventMsg::ThreadGoalUpdated(_),
|
||||||
) => true,
|
) => true,
|
||||||
|
RolloutItem::EventMsg(EventMsg::ItemCompleted(event))
|
||||||
|
if matches!(event.item, TurnItem::UserMessage(_)) =>
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
RolloutItem::EventMsg(_)
|
RolloutItem::EventMsg(_)
|
||||||
| RolloutItem::ResponseItem(_)
|
| RolloutItem::ResponseItem(_)
|
||||||
| RolloutItem::InterAgentCommunication(_)
|
| RolloutItem::InterAgentCommunication(_)
|
||||||
@@ -96,16 +101,11 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventMsg::UserMessage(user) => {
|
EventMsg::UserMessage(user) => {
|
||||||
let preview = user_message_preview(user);
|
apply_user_message(metadata, user);
|
||||||
if metadata.first_user_message.is_none() {
|
}
|
||||||
metadata.first_user_message = preview.clone();
|
EventMsg::ItemCompleted(event) => {
|
||||||
}
|
if let TurnItem::UserMessage(user) = &event.item {
|
||||||
set_preview_if_empty(metadata, preview);
|
apply_user_message(metadata, &user.as_legacy_user_message_event());
|
||||||
if metadata.title.is_empty() {
|
|
||||||
let title = strip_user_message_prefix(user.message.as_str());
|
|
||||||
if !title.is_empty() {
|
|
||||||
metadata.title = title.to_string();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventMsg::ThreadGoalUpdated(event) => {
|
EventMsg::ThreadGoalUpdated(event) => {
|
||||||
@@ -120,35 +120,26 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
|||||||
|
|
||||||
fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {}
|
fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {}
|
||||||
|
|
||||||
|
fn apply_user_message(metadata: &mut ThreadMetadata, user: &UserMessageEvent) {
|
||||||
|
let preview = user_message_preview(user);
|
||||||
|
if metadata.first_user_message.is_none() {
|
||||||
|
metadata.first_user_message = preview.clone();
|
||||||
|
}
|
||||||
|
set_preview_if_empty(metadata, preview);
|
||||||
|
if metadata.title.is_empty() {
|
||||||
|
let title = strip_user_message_prefix(user.message.as_str());
|
||||||
|
if !title.is_empty() {
|
||||||
|
metadata.title = title.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_preview_if_empty(metadata: &mut ThreadMetadata, preview: Option<String>) {
|
fn set_preview_if_empty(metadata: &mut ThreadMetadata, preview: Option<String>) {
|
||||||
if metadata.preview.is_none() {
|
if metadata.preview.is_none() {
|
||||||
metadata.preview = preview;
|
metadata.preview = preview;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_user_message_prefix(text: &str) -> &str {
|
|
||||||
match text.find(USER_MESSAGE_BEGIN) {
|
|
||||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
|
||||||
None => text.trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
|
|
||||||
let message = strip_user_message_prefix(user.message.as_str());
|
|
||||||
if !message.is_empty() {
|
|
||||||
return Some(message.to_string());
|
|
||||||
}
|
|
||||||
if user
|
|
||||||
.images
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|images| !images.is_empty())
|
|
||||||
|| !user.local_images.is_empty()
|
|
||||||
{
|
|
||||||
return Some(IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER.to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn enum_to_string<T: Serialize>(value: &T) -> String {
|
pub(crate) fn enum_to_string<T: Serialize>(value: &T) -> String {
|
||||||
match serde_json::to_value(value) {
|
match serde_json::to_value(value) {
|
||||||
Ok(Value::String(s)) => s,
|
Ok(Value::String(s)) => s,
|
||||||
@@ -164,12 +155,15 @@ mod tests {
|
|||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::items::TurnItem;
|
||||||
|
use codex_protocol::items::UserMessageItem;
|
||||||
use codex_protocol::models::ContentItem;
|
use codex_protocol::models::ContentItem;
|
||||||
use codex_protocol::models::PermissionProfile;
|
use codex_protocol::models::PermissionProfile;
|
||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
use codex_protocol::openai_models::ReasoningEffort;
|
use codex_protocol::openai_models::ReasoningEffort;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
use codex_protocol::protocol::AskForApproval;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
|
use codex_protocol::protocol::ItemCompletedEvent;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::SandboxPolicy;
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
use codex_protocol::protocol::SessionMeta;
|
use codex_protocol::protocol::SessionMeta;
|
||||||
@@ -182,6 +176,7 @@ mod tests {
|
|||||||
use codex_protocol::protocol::TurnContextItem;
|
use codex_protocol::protocol::TurnContextItem;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||||
use codex_protocol::protocol::UserMessageEvent;
|
use codex_protocol::protocol::UserMessageEvent;
|
||||||
|
use codex_protocol::user_input::UserInput;
|
||||||
|
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -229,6 +224,29 @@ mod tests {
|
|||||||
assert_eq!(metadata.title, "actual user request");
|
assert_eq!(metadata.title, "actual user request");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_user_message_items_set_title_and_first_user_message() {
|
||||||
|
let mut metadata = metadata_for_test();
|
||||||
|
let item = RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||||
|
thread_id: ThreadId::default(),
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
item: TurnItem::UserMessage(UserMessageItem::new(&[UserInput::Text {
|
||||||
|
text: format!("{USER_MESSAGE_BEGIN} actual user request"),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}])),
|
||||||
|
completed_at_ms: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
metadata.first_user_message.as_deref(),
|
||||||
|
Some("actual user request")
|
||||||
|
);
|
||||||
|
assert_eq!(metadata.preview.as_deref(), Some("actual user request"));
|
||||||
|
assert_eq!(metadata.title, "actual user request");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_msg_image_only_user_message_sets_image_placeholder_preview() {
|
fn event_msg_image_only_user_message_sets_image_placeholder_preview() {
|
||||||
let mut metadata = metadata_for_test();
|
let mut metadata = metadata_for_test();
|
||||||
@@ -243,14 +261,8 @@ mod tests {
|
|||||||
|
|
||||||
apply_rollout_item(&mut metadata, &item, "test-provider");
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(metadata.first_user_message.as_deref(), Some("[Image]"));
|
||||||
metadata.first_user_message.as_deref(),
|
assert_eq!(metadata.preview.as_deref(), Some("[Image]"));
|
||||||
Some(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
metadata.preview.as_deref(),
|
|
||||||
Some(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)
|
|
||||||
);
|
|
||||||
assert_eq!(metadata.title, "");
|
assert_eq!(metadata.title, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -482,17 +482,21 @@ impl InMemoryThreadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreResult<()> {
|
async fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreResult<()> {
|
||||||
let canonical_items = persisted_rollout_items(params.items.as_slice());
|
if params.items.is_empty() {
|
||||||
if canonical_items.is_empty() {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
|
let history_mode = history_mode_from_state(&state, params.thread_id);
|
||||||
|
let persisted_items = persisted_rollout_items(params.items.as_slice(), history_mode);
|
||||||
|
if persisted_items.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
state.calls.append_items += 1;
|
state.calls.append_items += 1;
|
||||||
state
|
state
|
||||||
.histories
|
.histories
|
||||||
.entry(params.thread_id)
|
.entry(params.thread_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
.extend(canonical_items);
|
.extend(persisted_items);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::ThreadMemoryMode;
|
use codex_protocol::protocol::ThreadMemoryMode;
|
||||||
use codex_rollout::RolloutPersistenceTelemetry;
|
use codex_rollout::RolloutPersistenceTelemetry;
|
||||||
use codex_rollout::measure_and_filter_rollout_items;
|
use codex_rollout::measure_and_filter_rollout_items;
|
||||||
@@ -32,6 +33,7 @@ use crate::thread_metadata_sync::ThreadMetadataSync;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LiveThread {
|
pub struct LiveThread {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
thread_store: Arc<dyn ThreadStore>,
|
thread_store: Arc<dyn ThreadStore>,
|
||||||
metadata_sync: Arc<Mutex<ThreadMetadataSync>>,
|
metadata_sync: Arc<Mutex<ThreadMetadataSync>>,
|
||||||
persistence_telemetry: RolloutPersistenceTelemetry,
|
persistence_telemetry: RolloutPersistenceTelemetry,
|
||||||
@@ -92,10 +94,12 @@ impl LiveThread {
|
|||||||
params: CreateThreadParams,
|
params: CreateThreadParams,
|
||||||
) -> ThreadStoreResult<Self> {
|
) -> ThreadStoreResult<Self> {
|
||||||
let thread_id = params.thread_id;
|
let thread_id = params.thread_id;
|
||||||
|
let history_mode = params.history_mode;
|
||||||
let metadata_sync = ThreadMetadataSync::for_create(¶ms).await;
|
let metadata_sync = ThreadMetadataSync::for_create(¶ms).await;
|
||||||
thread_store.create_thread(params).await?;
|
thread_store.create_thread(params).await?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
thread_id,
|
thread_id,
|
||||||
|
history_mode,
|
||||||
thread_store,
|
thread_store,
|
||||||
metadata_sync: Arc::new(Mutex::new(metadata_sync)),
|
metadata_sync: Arc::new(Mutex::new(metadata_sync)),
|
||||||
persistence_telemetry: RolloutPersistenceTelemetry::new(thread_id),
|
persistence_telemetry: RolloutPersistenceTelemetry::new(thread_id),
|
||||||
@@ -104,6 +108,7 @@ impl LiveThread {
|
|||||||
|
|
||||||
pub async fn resume(
|
pub async fn resume(
|
||||||
thread_store: Arc<dyn ThreadStore>,
|
thread_store: Arc<dyn ThreadStore>,
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
params: ResumeThreadParams,
|
params: ResumeThreadParams,
|
||||||
) -> ThreadStoreResult<Self> {
|
) -> ThreadStoreResult<Self> {
|
||||||
let thread_id = params.thread_id;
|
let thread_id = params.thread_id;
|
||||||
@@ -132,6 +137,7 @@ impl LiveThread {
|
|||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
thread_id,
|
thread_id,
|
||||||
|
history_mode,
|
||||||
thread_store,
|
thread_store,
|
||||||
metadata_sync: Arc::new(Mutex::new(metadata_sync)),
|
metadata_sync: Arc::new(Mutex::new(metadata_sync)),
|
||||||
persistence_telemetry: RolloutPersistenceTelemetry::new(thread_id),
|
persistence_telemetry: RolloutPersistenceTelemetry::new(thread_id),
|
||||||
@@ -141,36 +147,38 @@ impl LiveThread {
|
|||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
level = "trace",
|
level = "trace",
|
||||||
skip_all,
|
skip_all,
|
||||||
fields(item_count = items.len())
|
fields(item_count = raw_items.len())
|
||||||
)]
|
)]
|
||||||
pub async fn append_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> {
|
pub async fn append_items(&self, raw_items: &[RolloutItem]) -> ThreadStoreResult<()> {
|
||||||
// Empty appends are intentionally ignored rather than represented as zero-sized batches.
|
// Empty appends are intentionally ignored rather than represented as zero-sized batches.
|
||||||
if items.is_empty() {
|
if raw_items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let (canonical_items, measurement) = if self.persistence_telemetry.is_enabled() {
|
let (items, measurement) = if self.persistence_telemetry.is_enabled() {
|
||||||
let (canonical_items, measurement) = measure_and_filter_rollout_items(items);
|
let (items, measurement) =
|
||||||
(canonical_items, Some(measurement))
|
measure_and_filter_rollout_items(raw_items, self.history_mode);
|
||||||
|
(items, Some(measurement))
|
||||||
} else {
|
} else {
|
||||||
(persisted_rollout_items(items), None)
|
(persisted_rollout_items(raw_items, self.history_mode), None)
|
||||||
};
|
};
|
||||||
self.thread_store
|
self.thread_store
|
||||||
.append_items(AppendThreadItemsParams {
|
.append_items(AppendThreadItemsParams {
|
||||||
thread_id: self.thread_id,
|
thread_id: self.thread_id,
|
||||||
items: items.to_vec(),
|
items: raw_items.to_vec(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(measurement) = measurement.as_ref() {
|
if let Some(measurement) = measurement.as_ref() {
|
||||||
self.persistence_telemetry.record_batch(items, measurement);
|
self.persistence_telemetry
|
||||||
|
.record_batch(raw_items, measurement);
|
||||||
}
|
}
|
||||||
if canonical_items.is_empty() {
|
if items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let update = self
|
let update = self
|
||||||
.metadata_sync
|
.metadata_sync
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.observe_appended_items(canonical_items.as_slice());
|
.observe_appended_items(items.as_slice());
|
||||||
if let Some(update) = update {
|
if let Some(update) = update {
|
||||||
self.thread_store
|
self.thread_store
|
||||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::ThreadMemoryMode;
|
use codex_protocol::protocol::ThreadMemoryMode;
|
||||||
use codex_rollout::RolloutConfig;
|
use codex_rollout::RolloutConfig;
|
||||||
use codex_rollout::RolloutRecorder;
|
use codex_rollout::RolloutRecorder;
|
||||||
@@ -115,13 +116,15 @@ pub(super) async fn append_items(
|
|||||||
store: &LocalThreadStore,
|
store: &LocalThreadStore,
|
||||||
params: AppendThreadItemsParams,
|
params: AppendThreadItemsParams,
|
||||||
) -> ThreadStoreResult<()> {
|
) -> ThreadStoreResult<()> {
|
||||||
let canonical_items = persisted_rollout_items(params.items.as_slice());
|
// LocalThreadStore rejects paginated threads before opening a writer.
|
||||||
if canonical_items.is_empty() {
|
let persisted_items =
|
||||||
|
persisted_rollout_items(params.items.as_slice(), ThreadHistoryMode::Legacy);
|
||||||
|
if persisted_items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let recorder = store.live_recorder(params.thread_id).await?;
|
let recorder = store.live_recorder(params.thread_id).await?;
|
||||||
recorder
|
recorder
|
||||||
.record_canonical_items(canonical_items.as_slice())
|
.record_canonical_items(persisted_items.as_slice())
|
||||||
.await
|
.await
|
||||||
.map_err(thread_store_io_error)?;
|
.map_err(thread_store_io_error)?;
|
||||||
// LiveThread applies metadata immediately after append_items returns. Wait for the local
|
// LiveThread applies metadata immediately after append_items returns. Wait for the local
|
||||||
|
|||||||
@@ -694,6 +694,7 @@ mod tests {
|
|||||||
write_session_file(home.path(), "2025-01-03T17-00-00", uuid).expect("session file");
|
write_session_file(home.path(), "2025-01-03T17-00-00", uuid).expect("session file");
|
||||||
let live_thread = LiveThread::resume(
|
let live_thread = LiveThread::resume(
|
||||||
store,
|
store,
|
||||||
|
ThreadHistoryMode::Legacy,
|
||||||
ResumeThreadParams {
|
ResumeThreadParams {
|
||||||
thread_id,
|
thread_id,
|
||||||
rollout_path: Some(rollout_path),
|
rollout_path: Some(rollout_path),
|
||||||
@@ -748,6 +749,7 @@ mod tests {
|
|||||||
.expect("external session file");
|
.expect("external session file");
|
||||||
let live_thread = LiveThread::resume(
|
let live_thread = LiveThread::resume(
|
||||||
store,
|
store,
|
||||||
|
ThreadHistoryMode::Legacy,
|
||||||
ResumeThreadParams {
|
ResumeThreadParams {
|
||||||
thread_id,
|
thread_id,
|
||||||
rollout_path: Some(rollout_path),
|
rollout_path: Some(rollout_path),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#[cfg(test)]
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -9,29 +7,27 @@ use chrono::Utc;
|
|||||||
use codex_git_utils::collect_git_info;
|
use codex_git_utils::collect_git_info;
|
||||||
use codex_git_utils::get_git_repo_root;
|
use codex_git_utils::get_git_repo_root;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::items::TurnItem;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::GitInfo;
|
use codex_protocol::protocol::GitInfo;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::ThreadMemoryMode;
|
use codex_protocol::protocol::ThreadMemoryMode;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
|
||||||
use codex_protocol::protocol::UserMessageEvent;
|
use codex_protocol::protocol::UserMessageEvent;
|
||||||
|
use codex_protocol::protocol::strip_user_message_prefix;
|
||||||
|
use codex_protocol::protocol::user_message_preview;
|
||||||
|
|
||||||
use crate::CreateThreadParams;
|
use crate::CreateThreadParams;
|
||||||
use crate::GitInfoPatch;
|
use crate::GitInfoPatch;
|
||||||
use crate::ResumeThreadParams;
|
use crate::ResumeThreadParams;
|
||||||
use crate::ThreadMetadataPatch;
|
use crate::ThreadMetadataPatch;
|
||||||
|
|
||||||
const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]";
|
|
||||||
#[cfg(not(test))]
|
|
||||||
const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5);
|
const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
#[cfg(test)]
|
|
||||||
const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
/// Live-thread helper that derives metadata updates from canonical rollout items.
|
/// Live-thread helper that derives metadata updates from appended rollout items.
|
||||||
///
|
///
|
||||||
/// Stores receive raw history plus explicit metadata patches. This helper keeps append-derived
|
/// Stores receive raw rollout items plus explicit metadata patches. This helper
|
||||||
/// metadata observation in the live layer without owning persistence-policy filtering or making
|
/// keeps append-derived metadata observation in the live layer without owning persistence-policy
|
||||||
/// `append_items` infer metadata inside a `ThreadStore` implementation.
|
/// filtering or making `append_items` infer metadata inside a `ThreadStore` implementation.
|
||||||
pub(crate) struct ThreadMetadataSync {
|
pub(crate) struct ThreadMetadataSync {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
cwd_seen: bool,
|
cwd_seen: bool,
|
||||||
@@ -249,22 +245,14 @@ impl ThreadMetadataSync {
|
|||||||
update.permission_profile = Some(turn_ctx.permission_profile());
|
update.permission_profile = Some(turn_ctx.permission_profile());
|
||||||
}
|
}
|
||||||
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
|
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
|
||||||
if let Some(preview) = user_message_preview(user) {
|
self.observe_user_message(user, &mut update);
|
||||||
if !self.first_user_message_seen {
|
}
|
||||||
self.first_user_message_seen = true;
|
RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => {
|
||||||
update.first_user_message = Some(preview.clone());
|
if let TurnItem::UserMessage(user) = &event.item {
|
||||||
}
|
self.observe_user_message(
|
||||||
if !self.preview_seen {
|
&user.as_legacy_user_message_event(),
|
||||||
self.preview_seen = true;
|
&mut update,
|
||||||
update.preview = Some(preview);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
if !self.title_seen {
|
|
||||||
let title = strip_user_message_prefix(user.message.as_str());
|
|
||||||
if !title.is_empty() {
|
|
||||||
self.title_seen = true;
|
|
||||||
update.title = Some(title.to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RolloutItem::EventMsg(EventMsg::TokenCount(token_count)) => {
|
RolloutItem::EventMsg(EventMsg::TokenCount(token_count)) => {
|
||||||
@@ -293,6 +281,26 @@ impl ThreadMetadataSync {
|
|||||||
Some(update)
|
Some(update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_user_message(&mut self, user: &UserMessageEvent, update: &mut ThreadMetadataPatch) {
|
||||||
|
if let Some(preview) = user_message_preview(user) {
|
||||||
|
if !self.first_user_message_seen {
|
||||||
|
self.first_user_message_seen = true;
|
||||||
|
update.first_user_message = Some(preview.clone());
|
||||||
|
}
|
||||||
|
if !self.preview_seen {
|
||||||
|
self.preview_seen = true;
|
||||||
|
update.preview = Some(preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.title_seen {
|
||||||
|
let title = strip_user_message_prefix(user.message.as_str());
|
||||||
|
if !title.is_empty() {
|
||||||
|
self.title_seen = true;
|
||||||
|
update.title = Some(title.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_pending_update(&mut self, update: Option<ThreadMetadataPatch>) {
|
fn merge_pending_update(&mut self, update: Option<ThreadMetadataPatch>) {
|
||||||
let Some(update) = update else {
|
let Some(update) = update else {
|
||||||
return;
|
return;
|
||||||
@@ -323,29 +331,6 @@ fn parse_session_timestamp(value: &str) -> Option<DateTime<Utc>> {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_user_message_prefix(text: &str) -> &str {
|
|
||||||
match text.find(USER_MESSAGE_BEGIN) {
|
|
||||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
|
||||||
None => text.trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
|
|
||||||
let message = strip_user_message_prefix(user.message.as_str());
|
|
||||||
if !message.is_empty() {
|
|
||||||
return Some(message.to_string());
|
|
||||||
}
|
|
||||||
if user
|
|
||||||
.images
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|images| !images.is_empty())
|
|
||||||
|| !user.local_images.is_empty()
|
|
||||||
{
|
|
||||||
return Some(IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER.to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn thread_updated_at_touch() -> ThreadMetadataPatch {
|
fn thread_updated_at_touch() -> ThreadMetadataPatch {
|
||||||
ThreadMetadataPatch {
|
ThreadMetadataPatch {
|
||||||
updated_at: Some(Utc::now()),
|
updated_at: Some(Utc::now()),
|
||||||
@@ -387,7 +372,11 @@ fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use codex_protocol::items::UserMessageItem;
|
||||||
use codex_protocol::protocol::CompactedItem;
|
use codex_protocol::protocol::CompactedItem;
|
||||||
|
use codex_protocol::protocol::ItemCompletedEvent;
|
||||||
use codex_protocol::protocol::SessionMeta;
|
use codex_protocol::protocol::SessionMeta;
|
||||||
use codex_protocol::protocol::SessionMetaLine;
|
use codex_protocol::protocol::SessionMetaLine;
|
||||||
use codex_protocol::protocol::SessionSource;
|
use codex_protocol::protocol::SessionSource;
|
||||||
@@ -396,6 +385,7 @@ mod tests {
|
|||||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||||
use codex_protocol::protocol::TurnStartedEvent;
|
use codex_protocol::protocol::TurnStartedEvent;
|
||||||
use codex_protocol::protocol::UserMessageEvent;
|
use codex_protocol::protocol::UserMessageEvent;
|
||||||
|
use codex_protocol::user_input::UserInput;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -484,6 +474,32 @@ mod tests {
|
|||||||
assert!(update.patch.updated_at.is_some());
|
assert!(update.patch.updated_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_user_message_items_emit_metadata_fields() {
|
||||||
|
let thread_id = ThreadId::new();
|
||||||
|
let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new()));
|
||||||
|
let update = sync
|
||||||
|
.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::ItemCompleted(
|
||||||
|
ItemCompletedEvent {
|
||||||
|
thread_id,
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
item: TurnItem::UserMessage(UserMessageItem::new(&[UserInput::Text {
|
||||||
|
text: "first user text".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}])),
|
||||||
|
completed_at_ms: 0,
|
||||||
|
},
|
||||||
|
))])
|
||||||
|
.expect("completed user message metadata update");
|
||||||
|
|
||||||
|
assert_eq!(update.patch.preview.as_deref(), Some("first user text"));
|
||||||
|
assert_eq!(update.patch.title.as_deref(), Some("first user text"));
|
||||||
|
assert_eq!(
|
||||||
|
update.patch.first_user_message.as_deref(),
|
||||||
|
Some("first user text")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metadata_irrelevant_items_coalesce_updated_at_touches() {
|
fn metadata_irrelevant_items_coalesce_updated_at_touches() {
|
||||||
let thread_id = ThreadId::new();
|
let thread_id = ThreadId::new();
|
||||||
|
|||||||
Reference in new issue
Block a user