fix: preserve system item identity in live events
This commit is contained in:
@@ -1106,6 +1106,9 @@ pub enum Event {
|
||||
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
|
||||
/// wire-side are 1:1.
|
||||
SystemItem {
|
||||
/// Stable durable history identity. Synthetic compatibility events omit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
entry_id: Option<String>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
item: serde_json::Value,
|
||||
},
|
||||
@@ -2615,17 +2618,29 @@ mod tests {
|
||||
#[test]
|
||||
fn event_system_item_roundtrip() {
|
||||
let event = Event::SystemItem {
|
||||
entry_id: Some("entry-1".to_string()),
|
||||
item: serde_json::json!({"kind": "notification", "message": "hello"}),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "system_item");
|
||||
assert_eq!(parsed["data"]["entry_id"], "entry-1");
|
||||
assert_eq!(parsed["data"]["item"]["kind"], "notification");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::SystemItem { item } => assert_eq!(item["kind"], "notification"),
|
||||
Event::SystemItem { entry_id, item } => {
|
||||
assert_eq!(entry_id.as_deref(), Some("entry-1"));
|
||||
assert_eq!(item["kind"], "notification");
|
||||
}
|
||||
other => panic!("expected SystemItem, got {other:?}"),
|
||||
}
|
||||
|
||||
let legacy: Event = serde_json::from_value(serde_json::json!({
|
||||
"event": "system_item",
|
||||
"data": { "item": { "kind": "notification", "message": "legacy" } }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(legacy, Event::SystemItem { entry_id: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+14
-4
@@ -1213,7 +1213,7 @@ impl App {
|
||||
self.push_durable_run_error(message);
|
||||
}
|
||||
}
|
||||
Event::SystemItem { item } => {
|
||||
Event::SystemItem { item, .. } => {
|
||||
self.apply_system_item(&item);
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
@@ -4006,7 +4006,10 @@ mod completion_flow_tests {
|
||||
"slug": "build",
|
||||
"body": "[Workflow /build]\nRun the build",
|
||||
});
|
||||
app.handle_worker_event(Event::SystemItem { item });
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
entry_id: None,
|
||||
item,
|
||||
});
|
||||
|
||||
assert!(app.blocks.is_empty());
|
||||
}
|
||||
@@ -4443,7 +4446,10 @@ mod completion_flow_tests {
|
||||
"message": "hi",
|
||||
"body": "[Notification] hi",
|
||||
});
|
||||
app.handle_worker_event(Event::SystemItem { item });
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
entry_id: None,
|
||||
item,
|
||||
});
|
||||
assert!(matches!(
|
||||
app.blocks.as_slice(),
|
||||
[Block::Notify { message }] if message == "hi"
|
||||
@@ -4458,7 +4464,10 @@ mod completion_flow_tests {
|
||||
"event": { "kind": "turn_ended", "worker_name": "child" },
|
||||
"body": "[Notification] worker `child` finished a turn",
|
||||
});
|
||||
app.handle_worker_event(Event::SystemItem { item });
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
entry_id: None,
|
||||
item,
|
||||
});
|
||||
assert_eq!(app.blocks.len(), 1);
|
||||
match &app.blocks[0] {
|
||||
Block::WorkerEvent {
|
||||
@@ -4832,6 +4841,7 @@ mod completion_flow_tests {
|
||||
// Snapshot text injected through an active system item kind; legacy
|
||||
// workflow items are intentionally ignored and must not carry active state.
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
entry_id: None,
|
||||
item: serde_json::json!({
|
||||
"kind": "task_reminder",
|
||||
"body": snapshot,
|
||||
|
||||
@@ -5467,6 +5467,7 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
|
||||
WorkerInputKind::Compact
|
||||
| WorkerInputKind::ListRewindTargets
|
||||
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
|
||||
entry_id: None,
|
||||
item: serde_json::json!({
|
||||
"kind": "embedded_worker_command_input",
|
||||
"command": input.kind,
|
||||
@@ -8618,6 +8619,7 @@ mod tests {
|
||||
.observe_worker_event(
|
||||
&detail.worker_ref,
|
||||
protocol::Event::SystemItem {
|
||||
entry_id: Some("entry-1".to_string()),
|
||||
item: serde_json::json!({
|
||||
"kind": "notification",
|
||||
"message": "note",
|
||||
@@ -8631,14 +8633,15 @@ mod tests {
|
||||
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||
.unwrap();
|
||||
assert_eq!(observations.len(), 1);
|
||||
let protocol::Event::SystemItem { item } = &observations[0].payload else {
|
||||
let protocol::Event::SystemItem { entry_id, item } = &observations[0].payload else {
|
||||
panic!("committed notification observation must be a system item");
|
||||
};
|
||||
assert_eq!(entry_id.as_deref(), Some("entry-1"));
|
||||
assert_eq!(item["kind"], "notification");
|
||||
assert!(observations.iter().all(|observation| {
|
||||
!matches!(
|
||||
&observation.payload,
|
||||
protocol::Event::SystemItem { item }
|
||||
protocol::Event::SystemItem { item, .. }
|
||||
if item["kind"] == "embedded_worker_notification"
|
||||
)
|
||||
}));
|
||||
|
||||
@@ -88,8 +88,12 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
.map(|entry| Event::SessionEntryCommitted { entry })
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
let entry_id = Some(entry.metadata.entry_id.0.clone());
|
||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
Some(Event::SystemItem {
|
||||
entry_id,
|
||||
item: value,
|
||||
})
|
||||
}
|
||||
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
|
||||
other => {
|
||||
@@ -303,6 +307,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_item_log_entry_keeps_identity_in_live_event() {
|
||||
let entry_id = session_store::LoggedSessionHistoryEntryId::new();
|
||||
let event = live_log_entry_event(LogEntry::AnnotatedSystemItem {
|
||||
ts: session_store::segment_log::now_millis(),
|
||||
entry: session_store::LoggedSystemHistoryEntry {
|
||||
item: session_store::SystemItem::ResidentSummaryRefresh {
|
||||
body: "refresh".to_string(),
|
||||
prompt_provenance: None,
|
||||
},
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: entry_id.clone(),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction {
|
||||
operation_id: None,
|
||||
},
|
||||
derivation: None,
|
||||
},
|
||||
},
|
||||
extensions: Vec::new(),
|
||||
})
|
||||
.expect("SystemItem must be live-relevant");
|
||||
|
||||
match event {
|
||||
Event::SystemItem {
|
||||
entry_id: live_entry_id,
|
||||
item,
|
||||
} => {
|
||||
assert_eq!(live_entry_id.as_deref(), Some(entry_id.0.as_str()));
|
||||
assert_eq!(item["kind"], "resident_summary_refresh");
|
||||
}
|
||||
other => panic!("expected SystemItem, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_log_entry_maps_to_user_message_event() {
|
||||
let segments = vec![protocol::Segment::text("hello from log")];
|
||||
|
||||
@@ -2717,7 +2717,7 @@ async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() {
|
||||
tokio::select! {
|
||||
event = reader.next::<Event>() => {
|
||||
match event {
|
||||
Ok(Some(Event::SystemItem { ref item }))
|
||||
Ok(Some(Event::SystemItem { ref item, .. }))
|
||||
if item.get("kind").and_then(|k| k.as_str()) == Some("worker_event")
|
||||
&& item
|
||||
.pointer("/event/worker_name")
|
||||
|
||||
Reference in New Issue
Block a user