mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
502 lines
16 KiB
Rust
502 lines
16 KiB
Rust
use super::AnalyticsEventsQueue;
|
|
use super::AppInvocation;
|
|
use super::CodexAppMentionedEventRequest;
|
|
use super::CodexAppUsedEventRequest;
|
|
use super::CodexPluginEventRequest;
|
|
use super::CodexPluginUsedEventRequest;
|
|
use super::InvocationType;
|
|
use super::TrackAppMentionedJob;
|
|
use super::TrackAppUsedJob;
|
|
use super::TrackEventRequest;
|
|
use super::TrackEventsContext;
|
|
use super::TrackEventsJob;
|
|
use super::TrackPluginManagementJob;
|
|
use super::TrackPluginUsedJob;
|
|
use super::TrackSkillInvocationsJob;
|
|
use super::codex_app_metadata;
|
|
use super::codex_plugin_metadata;
|
|
use super::codex_plugin_used_metadata;
|
|
use super::normalize_path_for_skill_id;
|
|
use crate::config::ConfigBuilder;
|
|
use crate::plugins::AppConnectorId;
|
|
use crate::plugins::PluginCapabilitySummary;
|
|
use crate::plugins::PluginId;
|
|
use crate::plugins::PluginTelemetryMetadata;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use tempfile::TempDir;
|
|
use tokio::sync::mpsc;
|
|
|
|
fn expected_absolute_path(path: &PathBuf) -> String {
|
|
std::fs::canonicalize(path)
|
|
.unwrap_or_else(|_| path.to_path_buf())
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() {
|
|
let repo_root = PathBuf::from("/repo/root");
|
|
let skill_path = PathBuf::from("/repo/root/.codex/skills/doc/SKILL.md");
|
|
|
|
let path = normalize_path_for_skill_id(
|
|
Some("https://example.com/repo.git"),
|
|
Some(repo_root.as_path()),
|
|
skill_path.as_path(),
|
|
);
|
|
|
|
assert_eq!(path, ".codex/skills/doc/SKILL.md");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() {
|
|
let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md");
|
|
|
|
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
|
|
let expected = expected_absolute_path(&skill_path);
|
|
|
|
assert_eq!(path, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() {
|
|
let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md");
|
|
|
|
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
|
|
let expected = expected_absolute_path(&skill_path);
|
|
|
|
assert_eq!(path, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_path_for_skill_id_repo_root_not_in_skill_path_uses_absolute_path() {
|
|
let repo_root = PathBuf::from("/repo/root");
|
|
let skill_path = PathBuf::from("/other/path/.codex/skills/doc/SKILL.md");
|
|
|
|
let path = normalize_path_for_skill_id(
|
|
Some("https://example.com/repo.git"),
|
|
Some(repo_root.as_path()),
|
|
skill_path.as_path(),
|
|
);
|
|
let expected = expected_absolute_path(&skill_path);
|
|
|
|
assert_eq!(path, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn app_mentioned_event_serializes_expected_shape() {
|
|
let tracking = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
};
|
|
let event = TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
|
|
event_type: "codex_app_mentioned",
|
|
event_params: codex_app_metadata(
|
|
&tracking,
|
|
AppInvocation {
|
|
connector_id: Some("calendar".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
invocation_type: Some(InvocationType::Explicit),
|
|
},
|
|
),
|
|
});
|
|
|
|
let payload = serde_json::to_value(&event).expect("serialize app mentioned event");
|
|
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"event_type": "codex_app_mentioned",
|
|
"event_params": {
|
|
"connector_id": "calendar",
|
|
"thread_id": "thread-1",
|
|
"turn_id": "turn-1",
|
|
"app_name": "Calendar",
|
|
"product_client_id": crate::default_client::originator().value,
|
|
"invoke_type": "explicit",
|
|
"model_slug": "gpt-5"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_used_event_serializes_expected_shape() {
|
|
let tracking = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-2".to_string(),
|
|
turn_id: "turn-2".to_string(),
|
|
};
|
|
let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
|
event_type: "codex_app_used",
|
|
event_params: codex_app_metadata(
|
|
&tracking,
|
|
AppInvocation {
|
|
connector_id: Some("drive".to_string()),
|
|
app_name: Some("Google Drive".to_string()),
|
|
invocation_type: Some(InvocationType::Implicit),
|
|
},
|
|
),
|
|
});
|
|
|
|
let payload = serde_json::to_value(&event).expect("serialize app used event");
|
|
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"event_type": "codex_app_used",
|
|
"event_params": {
|
|
"connector_id": "drive",
|
|
"thread_id": "thread-2",
|
|
"turn_id": "turn-2",
|
|
"app_name": "Google Drive",
|
|
"product_client_id": crate::default_client::originator().value,
|
|
"invoke_type": "implicit",
|
|
"model_slug": "gpt-5"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_used_dedupe_is_keyed_by_turn_and_connector() {
|
|
let (sender, _receiver) = mpsc::channel(1);
|
|
let queue = AnalyticsEventsQueue {
|
|
sender,
|
|
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
};
|
|
let app = AppInvocation {
|
|
connector_id: Some("calendar".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
invocation_type: Some(InvocationType::Implicit),
|
|
};
|
|
|
|
let turn_1 = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
};
|
|
let turn_2 = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-2".to_string(),
|
|
};
|
|
|
|
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true);
|
|
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false);
|
|
assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_used_event_serializes_expected_shape() {
|
|
let tracking = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-3".to_string(),
|
|
turn_id: "turn-3".to_string(),
|
|
};
|
|
let event = TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
|
|
event_type: "codex_plugin_used",
|
|
event_params: codex_plugin_used_metadata(&tracking, sample_plugin_metadata()),
|
|
});
|
|
|
|
let payload = serde_json::to_value(&event).expect("serialize plugin used event");
|
|
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"event_type": "codex_plugin_used",
|
|
"event_params": {
|
|
"plugin_id": "sample@test",
|
|
"plugin_name": "sample",
|
|
"marketplace_name": "test",
|
|
"has_skills": true,
|
|
"mcp_server_count": 2,
|
|
"connector_ids": ["calendar", "drive"],
|
|
"product_client_id": crate::default_client::originator().value,
|
|
"thread_id": "thread-3",
|
|
"turn_id": "turn-3",
|
|
"model_slug": "gpt-5"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_management_event_serializes_expected_shape() {
|
|
let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest {
|
|
event_type: "codex_plugin_installed",
|
|
event_params: codex_plugin_metadata(sample_plugin_metadata()),
|
|
});
|
|
|
|
let payload = serde_json::to_value(&event).expect("serialize plugin installed event");
|
|
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"event_type": "codex_plugin_installed",
|
|
"event_params": {
|
|
"plugin_id": "sample@test",
|
|
"plugin_name": "sample",
|
|
"marketplace_name": "test",
|
|
"has_skills": true,
|
|
"mcp_server_count": 2,
|
|
"connector_ids": ["calendar", "drive"],
|
|
"product_client_id": crate::default_client::originator().value
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() {
|
|
let (sender, _receiver) = mpsc::channel(1);
|
|
let queue = AnalyticsEventsQueue {
|
|
sender,
|
|
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
};
|
|
let plugin = sample_plugin_metadata();
|
|
|
|
let turn_1 = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
};
|
|
let turn_2 = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-2".to_string(),
|
|
};
|
|
|
|
assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), true);
|
|
assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), false);
|
|
assert_eq!(queue.should_enqueue_plugin_used(&turn_2, &plugin), true);
|
|
}
|
|
|
|
#[test]
|
|
fn track_events_job_type_uses_expected_tag_values() {
|
|
let codex_home = TempDir::new().expect("tempdir should create");
|
|
let config = Arc::new(load_test_config(codex_home.path()));
|
|
let tracking = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
};
|
|
let cases = vec![
|
|
(
|
|
TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob {
|
|
config: Arc::clone(&config),
|
|
tracking: tracking.clone(),
|
|
invocations: Vec::new(),
|
|
}),
|
|
"skill_invocations",
|
|
),
|
|
(
|
|
TrackEventsJob::AppMentioned(TrackAppMentionedJob {
|
|
config: Arc::clone(&config),
|
|
tracking: tracking.clone(),
|
|
mentions: Vec::new(),
|
|
}),
|
|
"app_mentioned",
|
|
),
|
|
(
|
|
TrackEventsJob::AppUsed(TrackAppUsedJob {
|
|
config: Arc::clone(&config),
|
|
tracking: tracking.clone(),
|
|
app: AppInvocation {
|
|
connector_id: None,
|
|
app_name: None,
|
|
invocation_type: None,
|
|
},
|
|
}),
|
|
"app_used",
|
|
),
|
|
(
|
|
TrackEventsJob::PluginUsed(TrackPluginUsedJob {
|
|
config: Arc::clone(&config),
|
|
tracking: tracking.clone(),
|
|
plugin: sample_plugin_metadata(),
|
|
}),
|
|
"plugin_used",
|
|
),
|
|
(
|
|
TrackEventsJob::PluginInstalled(TrackPluginManagementJob {
|
|
config: Arc::clone(&config),
|
|
plugin: sample_plugin_metadata(),
|
|
}),
|
|
"plugin_installed",
|
|
),
|
|
(
|
|
TrackEventsJob::PluginUninstalled(TrackPluginManagementJob {
|
|
config: Arc::clone(&config),
|
|
plugin: sample_plugin_metadata(),
|
|
}),
|
|
"plugin_uninstalled",
|
|
),
|
|
(
|
|
TrackEventsJob::PluginEnabled(TrackPluginManagementJob {
|
|
config: Arc::clone(&config),
|
|
plugin: sample_plugin_metadata(),
|
|
}),
|
|
"plugin_enabled",
|
|
),
|
|
(
|
|
TrackEventsJob::PluginDisabled(TrackPluginManagementJob {
|
|
config,
|
|
plugin: sample_plugin_metadata(),
|
|
}),
|
|
"plugin_disabled",
|
|
),
|
|
];
|
|
|
|
for (job, expected) in cases {
|
|
assert_eq!(job.job_type(), expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn track_events_job_event_count_matches_underlying_payloads() {
|
|
let codex_home = TempDir::new().expect("tempdir should create");
|
|
let config = Arc::new(load_test_config(codex_home.path()));
|
|
let tracking = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
};
|
|
let skill_job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob {
|
|
config: Arc::clone(&config),
|
|
tracking: tracking.clone(),
|
|
invocations: vec![
|
|
super::SkillInvocation {
|
|
skill_name: "doc".to_string(),
|
|
skill_scope: codex_protocol::protocol::SkillScope::Repo,
|
|
skill_path: codex_home.path().join("SKILL.md"),
|
|
invocation_type: InvocationType::Explicit,
|
|
},
|
|
super::SkillInvocation {
|
|
skill_name: "doc-2".to_string(),
|
|
skill_scope: codex_protocol::protocol::SkillScope::Repo,
|
|
skill_path: codex_home.path().join("SKILL-2.md"),
|
|
invocation_type: InvocationType::Implicit,
|
|
},
|
|
],
|
|
});
|
|
let app_mentioned_job = TrackEventsJob::AppMentioned(TrackAppMentionedJob {
|
|
config: Arc::clone(&config),
|
|
tracking: tracking.clone(),
|
|
mentions: vec![
|
|
AppInvocation {
|
|
connector_id: Some("drive".to_string()),
|
|
app_name: Some("Drive".to_string()),
|
|
invocation_type: Some(InvocationType::Explicit),
|
|
},
|
|
AppInvocation {
|
|
connector_id: Some("calendar".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
invocation_type: Some(InvocationType::Implicit),
|
|
},
|
|
AppInvocation {
|
|
connector_id: Some("gmail".to_string()),
|
|
app_name: Some("Gmail".to_string()),
|
|
invocation_type: None,
|
|
},
|
|
],
|
|
});
|
|
let plugin_job = TrackEventsJob::PluginUsed(TrackPluginUsedJob {
|
|
config,
|
|
tracking,
|
|
plugin: sample_plugin_metadata(),
|
|
});
|
|
|
|
assert_eq!(skill_job.event_count(), 2);
|
|
assert_eq!(app_mentioned_job.event_count(), 3);
|
|
assert_eq!(plugin_job.event_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn event_counts_by_job_type_groups_mixed_events() {
|
|
let tracking = TrackEventsContext {
|
|
model_slug: "gpt-5".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
};
|
|
let events = vec![
|
|
TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
|
|
event_type: "codex_app_mentioned",
|
|
event_params: codex_app_metadata(
|
|
&tracking,
|
|
AppInvocation {
|
|
connector_id: Some("drive".to_string()),
|
|
app_name: Some("Drive".to_string()),
|
|
invocation_type: Some(InvocationType::Explicit),
|
|
},
|
|
),
|
|
}),
|
|
TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
|
event_type: "codex_app_used",
|
|
event_params: codex_app_metadata(
|
|
&tracking,
|
|
AppInvocation {
|
|
connector_id: Some("calendar".to_string()),
|
|
app_name: Some("Calendar".to_string()),
|
|
invocation_type: Some(InvocationType::Implicit),
|
|
},
|
|
),
|
|
}),
|
|
TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
|
|
event_type: "codex_app_mentioned",
|
|
event_params: codex_app_metadata(
|
|
&tracking,
|
|
AppInvocation {
|
|
connector_id: Some("gmail".to_string()),
|
|
app_name: Some("Gmail".to_string()),
|
|
invocation_type: None,
|
|
},
|
|
),
|
|
}),
|
|
];
|
|
|
|
assert_eq!(
|
|
super::event_counts_by_job_type(&events),
|
|
BTreeMap::from([("app_mentioned", 2), ("app_used", 1)])
|
|
);
|
|
}
|
|
|
|
fn sample_plugin_metadata() -> PluginTelemetryMetadata {
|
|
PluginTelemetryMetadata {
|
|
plugin_id: PluginId::parse("sample@test").expect("valid plugin id"),
|
|
capability_summary: Some(PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
description: None,
|
|
has_skills: true,
|
|
mcp_server_names: vec!["mcp-1".to_string(), "mcp-2".to_string()],
|
|
app_connector_ids: vec![
|
|
AppConnectorId("calendar".to_string()),
|
|
AppConnectorId("drive".to_string()),
|
|
],
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn load_test_config(codex_home: &std::path::Path) -> crate::config::Config {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("tokio runtime should build")
|
|
.block_on(
|
|
ConfigBuilder::default()
|
|
.codex_home(codex_home.to_path_buf())
|
|
.fallback_cwd(Some(codex_home.to_path_buf()))
|
|
.build(),
|
|
)
|
|
.expect("config should load")
|
|
}
|