use crate::{ConnectionId, SessionId};
use std::sync::atomic::{AtomicU64, Ordering};
pub trait DebugSink: Send + Sync {
fn emit(&self, event: DebugEvent);
}
pub struct NullDebugSink;
impl DebugSink for NullDebugSink {
fn emit(&self, _event: DebugEvent) {}
}
#[derive(Clone, Debug, PartialEq)]
pub struct DebugEvent {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub sequence: u64,
pub severity: DebugSeverity,
pub scope: DebugScope,
pub payload: DebugPayload,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum DebugSeverity {
Info,
Warning,
Error,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DebugScope {
pub runtime_id: Option<String>,
pub connection_id: Option<String>,
pub session_id: Option<crate::SessionId>,
pub turn_id: Option<String>,
pub tool_call_id: Option<String>,
pub provider_name: Option<String>,
pub model_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum DebugPayload {
Prompt(PromptDebugEvent),
Context(ContextDebugEvent),
Compaction(CompactionDebugEvent),
Tool(ToolDebugEvent),
Provider(ProviderDebugEvent),
Config(ConfigDebugEvent),
Skill(SkillDebugEvent),
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum PromptDebugEvent {
SystemPromptRendered {
fingerprint: String,
total_chars: usize,
sections: Vec<SectionSummary>,
changed: Option<bool>,
},
ModelInputInfluence {
source: InfluenceSource,
destination: InfluenceDestination,
effect: InfluenceEffect,
reason: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum InfluenceSource {
ContextPressure,
CompactionAvailability,
ToolAvailability,
SkillActivation,
ProviderGuidance,
ClientInstruction,
RepoInstruction,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum InfluenceDestination {
SystemPromptSection(String),
UserPromptRewrite,
ToolDefinition,
ContinuationContext,
RequestMetadata,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum InfluenceEffect {
Added,
Removed,
Changed,
Suppressed,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SectionSummary {
pub name: String,
pub owner: Option<String>,
pub temperature: Option<String>,
pub chars: usize,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum ContextDebugEvent {
SnapshotEstimated {
total_tokens: usize,
context_window_limit: Option<usize>,
compact_threshold_tokens: Option<usize>,
quality: crate::ContextQuality,
pressure: String,
categories: Vec<(String, usize, crate::ContextQuality)>,
accumulated_usage: Option<crate::context::TokenUsageTotals>,
},
PressureChanged {
old_pressure: String,
new_pressure: String,
reason: String,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum CompactionDebugEvent {
Requested {
topic_present: bool,
range_count: usize,
thresholds: Option<String>,
},
Rejected { reason: String },
Applied {
block_count: usize,
old_size_tokens: Option<usize>,
new_size_tokens: Option<usize>,
method: Option<String>,
pressure_state: String,
reduction_pct: Option<f64>,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum ToolDebugEvent {
ApprovalEvaluated {
tool_name: String,
approved: bool,
decision_source: String,
user_approval_requested: bool,
reason: String,
},
ExecutionStarted {
tool_name: String,
tool_source: String,
call_id: String,
},
ExecutionFinished {
tool_name: String,
call_id: String,
status: String,
duration_ms: Option<u64>,
truncated: bool,
reason: Option<String>,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum ProviderDebugEvent {
ModelSwitchQueued {
target_model: String,
target_provider: String,
},
ModelSwitchPlanCreated {
current_tokens: usize,
target_window: Option<usize>,
adaptation_needed: bool,
estimate_quality: String,
},
ModelSwitchApplied {
from_model: String,
from_provider: String,
to_model: String,
to_provider: String,
capability_diff: Option<String>,
},
ModelSwitchFailed {
target_model: String,
target_provider: String,
reason: String,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum ConfigDebugEvent {
RuntimeConfigured {
provider_name: String,
model_id: String,
approval_strategy: String,
context_management_enabled: bool,
prompt_composition_enabled: bool,
tool_policy: String,
plugin_enabled: bool,
mcp_enabled: bool,
skill_enabled: bool,
workspace_roots: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum SkillDebugEvent {
CatalogRefreshed {
sources: Vec<String>,
discovered_count: usize,
trusted_count: usize,
untrusted_count: usize,
diagnostic_count: usize,
},
AvailableToSession {
count: usize,
source_categories: Vec<String>,
},
ActivationSuccess {
skill_name: String,
source_kind: String,
activation_source: String,
},
ActivationRejected { skill_name: String, reason: String },
}
pub(crate) struct SequenceGenerator {
counter: AtomicU64,
}
impl SequenceGenerator {
pub(crate) fn new() -> Self {
Self {
counter: AtomicU64::new(1),
}
}
pub(crate) fn next(&self) -> u64 {
self.counter.fetch_add(1, Ordering::Relaxed)
}
}
impl Default for SequenceGenerator {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Default)]
pub struct DebugContext {
pub scope: DebugScope,
}
impl DebugContext {
pub fn new(runtime_id: Option<String>) -> Self {
Self {
scope: DebugScope {
runtime_id,
..DebugScope::default()
},
}
}
pub fn for_connection(&self, connection_id: ConnectionId) -> Self {
let mut fork = self.clone();
fork.scope.connection_id = Some(connection_id.0.to_string());
fork
}
pub fn for_session(&self, session_id: SessionId) -> Self {
let mut fork = self.clone();
fork.scope.session_id = Some(session_id);
fork
}
pub fn for_turn(&self, turn_id: impl Into<String>) -> Self {
let mut fork = self.clone();
fork.scope.turn_id = Some(turn_id.into());
fork
}
pub fn for_tool_call(&self, call_id: impl Into<String>) -> Self {
let mut fork = self.clone();
fork.scope.tool_call_id = Some(call_id.into());
fork
}
pub fn with_provider(&mut self, provider: impl Into<String>) -> &mut Self {
self.scope.provider_name = Some(provider.into());
self
}
pub fn with_model(&mut self, model: impl Into<String>) -> &mut Self {
self.scope.model_id = Some(model.into());
self
}
pub fn event(
&self,
sequence: u64,
severity: DebugSeverity,
payload: DebugPayload,
) -> DebugEvent {
DebugEvent {
timestamp: chrono::Utc::now(),
sequence,
severity,
scope: self.scope.clone(),
payload,
}
}
}
pub fn redact_system_prompt(
fingerprint: impl Into<String>,
total_chars: usize,
sections: Vec<crate::prompt::system::PromptSectionMetadata>,
changed: Option<bool>,
) -> PromptDebugEvent {
let section_summaries = sections
.into_iter()
.map(|meta| SectionSummary {
name: meta.title.to_string(),
owner: Some(format!("{:?}", meta.owner)),
temperature: Some(format!("{:?}", meta.temperature)),
chars: 0, })
.collect();
PromptDebugEvent::SystemPromptRendered {
fingerprint: fingerprint.into(),
total_chars,
sections: section_summaries,
changed,
}
}
pub fn redact_tool_execution(
tool_name: impl Into<String>,
call_id: impl Into<String>,
status: impl Into<String>,
duration_ms: Option<u64>,
truncated: bool,
reason: Option<String>,
) -> ToolDebugEvent {
ToolDebugEvent::ExecutionFinished {
tool_name: tool_name.into(),
call_id: call_id.into(),
status: status.into(),
duration_ms,
truncated,
reason,
}
}
pub fn redact_config(config: &crate::config::Config) -> ConfigDebugEvent {
ConfigDebugEvent::RuntimeConfigured {
provider_name: config.provider_name.clone().unwrap_or_default(),
model_id: config.model.clone(),
approval_strategy: format!("{:?}", config.default_approval_strategy),
context_management_enabled: config.context_management.enabled,
prompt_composition_enabled: config.prompt_composition.repo_instructions.enabled,
tool_policy: format!("{:?}", config.default_tool_policy),
plugin_enabled: config.plugins.enabled,
mcp_enabled: config.mcp.enabled,
skill_enabled: config.skills.enabled,
workspace_roots: config.workspace_roots.len(),
}
}
pub(crate) fn emit_debug(sink: &dyn DebugSink, event: DebugEvent) {
sink.emit(event);
}
#[cfg(test)]
pub(crate) mod test_helpers {
use super::*;
use std::sync::Mutex;
#[derive(Debug, Default)]
pub struct RecordingDebugSink {
events: Mutex<Vec<DebugEvent>>,
}
impl RecordingDebugSink {
pub fn new() -> Self {
Self::default()
}
pub fn take_events(&self) -> Vec<DebugEvent> {
std::mem::take(&mut *self.events.lock().unwrap())
}
pub fn events(&self) -> Vec<DebugEvent> {
self.events.lock().unwrap().clone()
}
pub fn len(&self) -> usize {
self.events.lock().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.events.lock().unwrap().is_empty()
}
pub fn has_event<F>(&self, predicate: F) -> bool
where
F: Fn(&DebugEvent) -> bool,
{
self.events().iter().any(predicate)
}
}
impl DebugSink for RecordingDebugSink {
fn emit(&self, event: DebugEvent) {
self.events.lock().unwrap().push(event);
}
}
}
#[cfg(test)]
mod tests {
use super::test_helpers::RecordingDebugSink;
use super::*;
#[test]
fn null_sink_is_no_op() {
let sink = NullDebugSink;
let event = DebugEvent {
timestamp: chrono::Utc::now(),
sequence: 1,
severity: DebugSeverity::Info,
scope: DebugScope::default(),
payload: DebugPayload::Config(ConfigDebugEvent::RuntimeConfigured {
provider_name: "test".to_string(),
model_id: "test".to_string(),
approval_strategy: "auto".to_string(),
context_management_enabled: false,
prompt_composition_enabled: false,
tool_policy: "auto".to_string(),
plugin_enabled: false,
mcp_enabled: false,
skill_enabled: false,
workspace_roots: 0,
}),
};
sink.emit(event);
}
#[test]
fn recording_sink_captures_events() {
let sink = RecordingDebugSink::new();
assert!(sink.is_empty());
let event = DebugEvent {
timestamp: chrono::Utc::now(),
sequence: 1,
severity: DebugSeverity::Info,
scope: DebugScope::default(),
payload: DebugPayload::Config(ConfigDebugEvent::RuntimeConfigured {
provider_name: "test".to_string(),
model_id: "test".to_string(),
approval_strategy: "auto".to_string(),
context_management_enabled: false,
prompt_composition_enabled: false,
tool_policy: "auto".to_string(),
plugin_enabled: false,
mcp_enabled: false,
skill_enabled: false,
workspace_roots: 0,
}),
};
sink.emit(event.clone());
assert_eq!(sink.len(), 1);
assert!(sink.has_event(|e| e.sequence == 1));
let events = sink.take_events();
assert_eq!(events.len(), 1);
assert!(sink.is_empty());
}
#[test]
fn debug_context_builds_scope() {
let ctx = DebugContext::new(Some("runtime-1".to_string()));
assert_eq!(ctx.scope.runtime_id, Some("runtime-1".to_string()));
let conn_ctx = ctx.for_connection(crate::ConnectionId(0));
assert!(conn_ctx.scope.connection_id.is_some());
assert_eq!(conn_ctx.scope.runtime_id, Some("runtime-1".to_string()));
let session_ctx = conn_ctx.for_session(crate::SessionId::new());
assert!(session_ctx.scope.session_id.is_some());
assert!(session_ctx.scope.connection_id.is_some());
let turn_ctx = session_ctx.for_turn("turn-1");
assert_eq!(turn_ctx.scope.turn_id, Some("turn-1".to_string()));
let tool_ctx = turn_ctx.for_tool_call("call-1");
assert_eq!(tool_ctx.scope.tool_call_id, Some("call-1".to_string()));
}
#[test]
fn debug_event_envelope_fields_present() {
let event = DebugEvent {
timestamp: chrono::Utc::now(),
sequence: 42,
severity: DebugSeverity::Warning,
scope: DebugScope::default(),
payload: DebugPayload::Tool(ToolDebugEvent::ExecutionStarted {
tool_name: "test".to_string(),
tool_source: "builtin".to_string(),
call_id: "call-1".to_string(),
}),
};
assert_eq!(event.sequence, 42);
assert!(matches!(event.severity, DebugSeverity::Warning));
assert!(matches!(event.payload, DebugPayload::Tool(_)));
}
#[test]
fn redact_config_omits_sensitive_values() {
use crate::config::Config;
let config = Config::default();
let event = redact_config(&config);
match event {
ConfigDebugEvent::RuntimeConfigured {
approval_strategy,
tool_policy,
workspace_roots,
..
} => {
assert!(!approval_strategy.is_empty());
assert!(!tool_policy.is_empty());
assert_eq!(workspace_roots, config.workspace_roots.len());
}
}
}
#[test]
fn sequence_generator_increments() {
let gen = SequenceGenerator::new();
assert_eq!(gen.next(), 1);
assert_eq!(gen.next(), 2);
assert_eq!(gen.next(), 3);
}
#[test]
fn prompt_system_prompt_rendered_event_has_safe_fields() {
let event = PromptDebugEvent::SystemPromptRendered {
fingerprint: "abc123".to_string(),
total_chars: 1500,
sections: vec![SectionSummary {
name: "Identity".to_string(),
owner: Some("Core".to_string()),
temperature: Some("Cold".to_string()),
chars: 0,
}],
changed: Some(true),
};
match event {
PromptDebugEvent::SystemPromptRendered {
fingerprint,
total_chars,
sections,
changed,
} => {
assert_eq!(fingerprint, "abc123");
assert_eq!(total_chars, 1500);
assert_eq!(sections.len(), 1);
assert_eq!(changed, Some(true));
}
_ => panic!("unexpected variant"),
}
}
#[test]
fn prompt_model_input_influence_event_represents_hint() {
let event = PromptDebugEvent::ModelInputInfluence {
source: InfluenceSource::ContextPressure,
destination: InfluenceDestination::SystemPromptSection("Tool Philosophy".to_string()),
effect: InfluenceEffect::Added,
reason: "context pressure guidance: Soft".to_string(),
};
match event {
PromptDebugEvent::ModelInputInfluence {
source,
destination: _destination,
effect,
reason,
} => {
assert!(matches!(source, InfluenceSource::ContextPressure));
assert!(matches!(effect, InfluenceEffect::Added));
assert!(reason.contains("Soft"));
}
_ => panic!("unexpected variant"),
}
}
#[test]
fn provider_model_switch_failed_event_has_reason() {
let event = ProviderDebugEvent::ModelSwitchFailed {
target_model: "gpt-4".to_string(),
target_provider: "openai".to_string(),
reason: "session not found".to_string(),
};
match event {
ProviderDebugEvent::ModelSwitchFailed {
target_model,
target_provider,
reason,
} => {
assert_eq!(target_model, "gpt-4");
assert_eq!(target_provider, "openai");
assert_eq!(reason, "session not found");
}
_ => panic!("unexpected variant"),
}
}
}