use crate::{
core::extensions::{ExtensionUiRequest, RichSpan, request_ui},
extensions::util::truncate_chars,
modes::interactive::app::{App, MsgLevel},
};
use serde_json::{Value, json};
use std::sync::{
Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
const ENTRY_TYPE: &str = "debug-provider-events";
const MAX_EVENTS: usize = 2000;
const CARD_INLINE_EVENTS: usize = 8;
const CARD_LINE_MAX: usize = 200;
static ENABLED: AtomicBool = AtomicBool::new(false);
static DROPPED: AtomicUsize = AtomicUsize::new(0);
static EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());
static LAST: Mutex<Option<Value>> = Mutex::new(None);
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
fn take_events() -> (Vec<Value>, usize) {
let mut buf = EVENTS.lock().unwrap();
let events = std::mem::take(&mut *buf);
let dropped = DROPPED.swap(0, Ordering::Relaxed);
(events, dropped)
}
pub(super) fn reset_capture() {
EVENTS.lock().unwrap().clear();
DROPPED.store(0, Ordering::Relaxed);
}
pub(super) fn on_stream_event(event: &Value) {
if !enabled() {
return;
}
let Some(data) = event.get("data") else {
return;
};
let mut buf = EVENTS.lock().unwrap();
if buf.len() >= MAX_EVENTS {
DROPPED.fetch_add(1, Ordering::Relaxed);
return;
}
buf.push(data.clone());
}
pub(super) fn on_agent_event(event: &Value) {
match event.get("type").and_then(|v| v.as_str()) {
Some("turn_start") => reset_capture(),
Some("message_end") => flush_assistant(event),
Some("agent_end") => reset_capture(),
_ => {}
}
}
pub(super) fn render_custom_message(custom_type: &str, data: &Value) -> Option<Vec<RichSpan>> {
if custom_type != ENTRY_TYPE {
return None;
}
let provider = data.get("provider").and_then(|v| v.as_str()).unwrap_or("?");
let model = data.get("model").and_then(|v| v.as_str()).unwrap_or("?");
let api = data.get("api").and_then(|v| v.as_str()).unwrap_or("?");
let events = data.get("events").and_then(|v| v.as_array());
let count = events.map(|e| e.len()).unwrap_or(0);
let mut spans = vec![RichSpan {
fg: Some("accent".to_string()),
text: format!("{provider}/{model} ({api}) · {count} raw event(s)"),
}];
if let Some(events) = events {
for ev in events.iter().take(CARD_INLINE_EVENTS) {
spans.push(RichSpan::plain(format!(
"\n {}",
truncate_chars(&ev.to_string(), CARD_LINE_MAX)
)));
}
if count > CARD_INLINE_EVENTS {
spans.push(RichSpan {
fg: Some("dim".to_string()),
text: format!(
"\n … {} more (run /debug provider dump for the full payload)",
count - CARD_INLINE_EVENTS
),
});
}
}
Some(spans)
}
pub(super) fn command(st: &mut App, args: &str) {
match args.trim().to_lowercase().as_str() {
"" => set_enabled(st, !enabled()),
"on" => set_enabled(st, true),
"off" => set_enabled(st, false),
"dump" => dump(st),
other => st.push_msg(
format!("Usage: /debug provider [on|off|dump] (got `{other}`)"),
MsgLevel::Warning,
),
}
}
fn set_enabled(st: &mut App, value: bool) {
ENABLED.store(value, Ordering::Relaxed);
reset_capture();
if !value {
*LAST.lock().unwrap() = None;
}
st.push_msg(
format!(
"Provider event capture {}",
if value { "enabled" } else { "disabled" }
),
MsgLevel::Info,
);
}
fn dump(st: &mut App) {
let Some(payload) = LAST.lock().unwrap().clone() else {
st.push_msg(
"No captured provider events. Run /debug provider on first.".to_string(),
MsgLevel::Info,
);
return;
};
let text = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
st.push_msg(text, MsgLevel::Info);
}
fn flush_assistant(event: &Value) {
if !enabled() {
return;
}
let Some(raw) = event.get("message") else {
return;
};
if raw.get("role").and_then(|v| v.as_str()) != Some("assistant") {
return;
}
let (events, dropped) = take_events();
if events.is_empty() {
return;
}
let payload = capture_payload(raw, events, dropped);
*LAST.lock().unwrap() = Some(payload.clone());
request_ui(ExtensionUiRequest::PersistSessionEntry {
custom_type: ENTRY_TYPE.to_string(),
data: payload.clone(),
});
request_ui(ExtensionUiRequest::CustomMessage {
label: "provider debug".to_string(),
custom_type: ENTRY_TYPE.to_string(),
data: payload,
});
}
fn capture_payload(message: &Value, events: Vec<Value>, dropped: usize) -> Value {
json!({
"provider": message.get("provider").and_then(|v| v.as_str()).unwrap_or("?"),
"api": message.get("api").and_then(|v| v.as_str()).unwrap_or("?"),
"model": message.get("model").and_then(|v| v.as_str()).unwrap_or("?"),
"dropped": dropped,
"events": events,
})
}
#[cfg(test)]
pub(super) fn reset_for_test() {
ENABLED.store(false, Ordering::Relaxed);
reset_capture();
*LAST.lock().unwrap() = None;
}
#[cfg(test)]
fn peek_events() -> Vec<Value> {
EVENTS.lock().unwrap().clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
core::extensions::{Extension, dispatch_provider_stream_event},
extensions::debug::Debug,
test_support::{AUTH_TEST_LOCK, AgentDirGuard},
};
fn lock() -> std::sync::MutexGuard<'static, ()> {
AUTH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn capture_is_off_until_enabled() {
let _g = lock();
let _ad = AgentDirGuard::temp();
super::super::tests::register_enabled();
dispatch_provider_stream_event("openai", "openai-completions", "m", &json!({"n": 1}));
assert!(peek_events().is_empty(), "捕获开关关闭时不缓冲");
ENABLED.store(true, Ordering::Relaxed);
dispatch_provider_stream_event("openai", "openai-completions", "m", &json!({"n": 2}));
let captured = peek_events();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0]["n"], 2);
super::super::tests::cleanup();
}
#[test]
fn assistant_message_end_emits_card() {
let _g = lock();
let ext = Debug;
ENABLED.store(true, Ordering::Relaxed);
reset_capture();
ext.provider_stream_event(&json!({"data": {"type": "response.created"}}));
ext.on_agent_event(&json!({
"type": "message_end",
"message": {"role": "assistant", "provider": "openai", "api": "openai-completions", "model": "gpt-x"}
}));
assert!(
peek_events().is_empty(),
"flush 后缓冲清空(事件已随载荷交出)"
);
let payload = capture_payload(
&json!({"provider": "openai", "api": "openai-completions", "model": "gpt-x"}),
vec![json!({"a": 1})],
0,
);
assert_eq!(payload["provider"], "openai");
assert_eq!(payload["events"].as_array().map(|e| e.len()), Some(1));
let rendered = ext
.render_custom_message(ENTRY_TYPE, &payload)
.expect("应认领自己的 custom_type");
assert!(rendered[0].text.contains("openai/gpt-x"));
assert!(rendered[0].text.contains("1 raw event"));
assert!(
ext.render_custom_message("someone-else", &json!({}))
.is_none()
);
reset_for_test();
}
#[test]
fn dump_prints_last_completed_payload() {
let _g = lock();
ENABLED.store(true, Ordering::Relaxed);
reset_capture();
*LAST.lock().unwrap() = Some(json!({
"provider": "openai",
"api": "openai-completions",
"model": "gpt-x",
"dropped": 0,
"events": [{"type": "response.created"}],
}));
let mut st = App::new();
command(&mut st, "dump");
assert!(
st.system_messages.iter().any(|(_, m)| {
let text = crate::modes::interactive::app::sys_spans_text(&m.spans);
text.contains("openai") && text.contains("response.created")
}),
"dump 应打印最近一轮已完成的载荷"
);
let mut st = App::new();
reset_for_test();
command(&mut st, "dump");
assert!(st.system_messages.iter().any(|(_, m)| {
crate::modes::interactive::app::sys_spans_text(&m.spans).contains("No captured")
}));
reset_for_test();
}
#[test]
fn toggle_reports_state_and_rejects_unknown_action() {
let _g = lock();
reset_for_test();
let mut st = App::new();
command(&mut st, "");
assert!(enabled(), "无参切换为开启");
assert!(st.system_messages.iter().any(|(_, m)| {
crate::modes::interactive::app::sys_spans_text(&m.spans).contains("enabled")
}));
let mut st2 = App::new();
command(&mut st2, "off");
assert!(!enabled(), "off 关闭捕获");
let mut st3 = App::new();
command(&mut st3, "bogus");
assert!(st3.system_messages.iter().any(|(_, m)| {
crate::modes::interactive::app::sys_spans_text(&m.spans)
.contains("Usage: /debug provider")
}));
reset_for_test();
}
}