diff --git a/crates/core/tests/unit/atif_tests.rs b/crates/core/tests/unit/atif_tests.rs index 090eae10..236aa297 100644 --- a/crates/core/tests/unit/atif_tests.rs +++ b/crates/core/tests/unit/atif_tests.rs @@ -900,6 +900,79 @@ fn test_exporter_anthropic_messages_lifecycle_promotes_tool_use_blocks() { ); } +#[test] +fn test_exporter_openclaw_placeholder_replay_preserves_empty_user_step_and_raw_request() { + let exporter = AtifExporter::new("session-1".to_string(), make_agent_info()); + let llm_uuid = Uuid::now_v7(); + let base = base_timestamp(); + + let mut start = event_builder(llm_uuid, EventType::Start) + .name("openclaw-model-call") + .scope_type(ScopeType::Llm) + .input(json!({ + "headers": {}, + "content": { + "provider": "nvidia-inference", + "model": "claude-sonnet-4", + "prompt": "", + "messages": [], + "imagesCount": 0, + "placeholderRequest": true, + "source": "openclaw.llm_output" + } + })) + .model_name("claude-sonnet-4") + .build(); + let mut end = event_builder(llm_uuid, EventType::End) + .name("openclaw-model-call") + .scope_type(ScopeType::Llm) + .output(json!({ + "role": "assistant", + "content": "I will search.", + "assistant_texts_count": 1, + "openclaw": { + "assistant_tool_call_names": [] + } + })) + .model_name("claude-sonnet-4") + .build(); + + for (offset, event) in [&mut start, &mut end].into_iter().enumerate() { + set_event_timestamp(event, base + chrono::Duration::milliseconds(offset as i64)); + } + + { + let mut state = exporter.state.lock().unwrap(); + state.events.extend([start, end]); + } + + let trajectory = exporter.export().unwrap(); + assert_atif_v17_shape(&trajectory); + assert_eq!(trajectory.steps.len(), 2); + + let user_step = &trajectory.steps[0]; + assert_eq!(user_step.source, "user"); + assert_eq!(user_step.message, json!("")); + let user_extra: AtifStepExtra = + serde_json::from_value(user_step.extra.clone().unwrap()).unwrap(); + let llm_request = user_extra.llm_request.unwrap(); + assert!(llm_request.get("headers").is_none()); + assert_eq!(llm_request["placeholderRequest"], json!(true)); + assert_eq!(llm_request["messages"], json!([])); + assert_eq!(llm_request["prompt"], json!("")); + assert_eq!(llm_request["source"], json!("openclaw.llm_output")); + + let agent_step = &trajectory.steps[1]; + assert_eq!(agent_step.source, "agent"); + assert_eq!(agent_step.message, json!("I will search.")); + assert_eq!(agent_step.model_name, Some("claude-sonnet-4".to_string())); + let agent_extra: AtifStepExtra = + serde_json::from_value(agent_step.extra.clone().unwrap()).unwrap(); + let llm_response = agent_extra.llm_response.unwrap(); + assert_eq!(llm_response["role"], json!("assistant")); + assert_eq!(llm_response["content"], json!("I will search.")); +} + #[test] fn test_openai_responses_input_extracts_latest_user_content_block() { let message = extract_user_messages(&json!({ diff --git a/crates/core/tests/unit/observability/atof_tests.rs b/crates/core/tests/unit/observability/atof_tests.rs index 870078f4..39827af1 100644 --- a/crates/core/tests/unit/observability/atof_tests.rs +++ b/crates/core/tests/unit/observability/atof_tests.rs @@ -169,6 +169,34 @@ fn openclaw_agent_scope_event( )) } +fn openclaw_replay_llm_event( + uuid: Uuid, + parent_uuid: Option, + scope_category: ScopeCategory, + data: serde_json::Value, +) -> Event { + Event::Scope(ScopeEvent::new( + BaseEvent::builder() + .uuid(uuid) + .parent_uuid_opt(parent_uuid) + .name("openclaw-model-call") + .data(data) + .metadata(json!({ + "source": "openclaw.llm_output", + "hook_event_name": "llm_output" + })) + .build(), + scope_category, + Vec::new(), + EventCategory::llm(), + Some( + CategoryProfile::builder() + .model_name("claude-sonnet-4") + .build(), + ), + )) +} + fn read_jsonl(path: &Path) -> Vec { fs::read_to_string(path) .unwrap() @@ -570,6 +598,76 @@ fn openclaw_subagent_events_preserve_nested_and_fallback_parent_uuid() { ); } +#[test] +fn subscriber_preserves_openclaw_placeholder_replay_payloads_as_raw_jsonl() { + let dir = temp_dir("atof-openclaw-placeholder"); + let exporter = AtofExporter::new( + AtofExporterConfig::new() + .with_output_directory(&dir) + .with_filename("events.jsonl"), + ) + .unwrap(); + let subscriber = exporter.subscriber(); + + let uuid = Uuid::now_v7(); + let parent_uuid = Uuid::now_v7(); + let events = [ + openclaw_replay_llm_event( + uuid, + Some(parent_uuid), + ScopeCategory::Start, + json!({ + "headers": {}, + "content": { + "provider": "nvidia-inference", + "model": "claude-sonnet-4", + "prompt": "", + "messages": [], + "imagesCount": 0, + "placeholderRequest": true, + "source": "openclaw.llm_output" + } + }), + ), + openclaw_replay_llm_event( + uuid, + Some(parent_uuid), + ScopeCategory::End, + json!({ + "role": "assistant", + "content": "I will search.", + "assistant_texts_count": 1, + "openclaw": { + "assistant_tool_call_names": [] + } + }), + ), + ]; + + for event in &events { + subscriber(event); + } + exporter.force_flush().unwrap(); + + let lines = read_jsonl(exporter.path()); + assert_eq!(lines.len(), events.len()); + for (line, event) in lines.iter().zip(events.iter()) { + assert_eq!(line, &event.try_to_json_value().unwrap()); + assert_eq!(line["kind"], "scope"); + assert_eq!(line["atof_version"], "0.1"); + assert_eq!(line["parent_uuid"], parent_uuid.to_string()); + assert_eq!(line["category"], "llm"); + assert_eq!(line["metadata"]["source"], "openclaw.llm_output"); + assert_eq!(line["metadata"]["hook_event_name"], "llm_output"); + } + + assert_eq!(lines[0]["scope_category"], "start"); + assert_eq!(lines[0]["data"]["content"]["placeholderRequest"], true); + assert_eq!(lines[0]["data"]["content"]["messages"], json!([])); + assert_eq!(lines[1]["scope_category"], "end"); + assert_eq!(lines[1]["data"]["content"], "I will search."); +} + #[test] fn register_deregister_flush_and_shutdown_work_with_runtime_events() { let _guard = crate::observability::test_mutex().lock().unwrap(); diff --git a/crates/core/tests/unit/observability/openinference_tests.rs b/crates/core/tests/unit/observability/openinference_tests.rs index 715a3cf8..fc8d11f6 100644 --- a/crates/core/tests/unit/observability/openinference_tests.rs +++ b/crates/core/tests/unit/observability/openinference_tests.rs @@ -882,6 +882,80 @@ fn openclaw_subagent_scopes_preserve_nested_and_fallback_parent_linkage() { ); } +#[test] +fn openclaw_placeholder_replay_falls_back_to_sanitized_json_input_value() { + let (provider, exporter) = make_provider(); + let mut processor = + OpenInferenceEventProcessor::new(provider.clone(), "test-scope".to_string()); + let uuid = Uuid::now_v7(); + + processor.process(&make_start_event( + uuid, + None, + "openclaw-model-call", + ScopeType::Llm, + Some(json!({ + "headers": {"authorization": "Bearer secret-token"}, + "content": { + "provider": "nvidia-inference", + "model": "claude-sonnet-4", + "prompt": "", + "messages": [], + "imagesCount": 0, + "placeholderRequest": true, + "source": "openclaw.llm_output" + } + })), + )); + processor.process(&make_end_event( + uuid, + None, + "openclaw-model-call", + ScopeType::Llm, + Some(json!({ + "role": "assistant", + "content": "I will search.", + "assistant_texts_count": 1, + "openclaw": { + "assistant_tool_call_names": [] + } + })), + )); + + processor.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + let attributes = attr_map(&spans[0].attributes); + assert_attr(&attributes, "llm.provider", "nvidia-inference"); + assert_attr( + &attributes, + "llm.output_messages.0.message.role", + "assistant", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.content", + "I will search.", + ); + assert!(!attributes.contains_key("llm.input_messages.0.message.role")); + assert!(!attributes.contains_key("llm.input_messages.0.message.content")); + assert_attr(&attributes, "input.mime_type", "application/json"); + + let input_value = attributes.get("input.value").expect("missing input.value"); + let parsed_input: serde_json::Value = serde_json::from_str(input_value).unwrap(); + assert!(parsed_input.get("headers").is_none()); + assert_eq!(parsed_input["content"]["placeholderRequest"], json!(true)); + assert_eq!(parsed_input["content"]["messages"], json!([])); + assert_eq!(parsed_input["content"]["prompt"], json!("")); + assert_eq!( + parsed_input["content"]["source"], + json!("openclaw.llm_output") + ); + assert_no_attr_contains(&attributes, "authorization"); + assert_no_attr_contains(&attributes, "secret-token"); +} + #[test] fn generic_unannotated_llm_output_does_not_emit_flattened_output_message_attrs() { let (provider, exporter) = make_provider();