Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/commands/checkpoint_agent/presets/github_copilot/ide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ pub(super) fn parse_vscode_native_hooks(
model_extraction::extract_model(path, sweep_format, None)
.ok()
.flatten()
.or_else(|| {
model_extraction::extract_model_from_copilot_otel_db(path)
.ok()
.flatten()
})
.or_else(|| {
model_extraction::extract_model_from_copilot_models_json(path)
.ok()
Expand Down
242 changes: 242 additions & 0 deletions src/streams/model_extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,90 @@ pub fn extract_model_from_copilot_models_json(
Ok(model)
}

/// Resolves the path to VS Code Copilot's OTEL traces SQLite DB (`agent-traces.db`)
/// from a transcript path.
///
/// Honors the `GIT_AI_COPILOT_OTEL_DB_PATH` override, otherwise navigates:
/// `.../workspaceStorage/{hash}/GitHub.copilot-chat/transcripts/{id}.jsonl`
/// → `.../User/globalStorage/github.copilot-chat/agent-traces.db`.
fn resolve_copilot_otel_db_path(stream_path: &Path) -> Option<std::path::PathBuf> {
if let Ok(path) = std::env::var("GIT_AI_COPILOT_OTEL_DB_PATH") {
let p = std::path::PathBuf::from(path);
if p.exists() {
return Some(p);
}
}

// transcript: .../workspaceStorage/{hash}/GitHub.copilot-chat/transcripts/{id}.jsonl
let workspace_storage_root = stream_path
.parent()? // transcripts/
.parent()? // GitHub.copilot-chat/
.parent()? // {hash}/
.parent()?; // workspaceStorage/

let user_dir = workspace_storage_root.parent()?; // User/
let otel_db = user_dir
.join("globalStorage")
.join("github.copilot-chat")
.join("agent-traces.db");

if otel_db.exists() {
Some(otel_db)
} else {
None
}
}

/// Extracts the model from VS Code Copilot's OTEL traces DB (`agent-traces.db`).
///
/// VS Code does not record the model in its transcript JSONL, so the only
/// reliable per-session source of the user-selected model is the OTEL spans DB.
/// Given a transcript path like `.../transcripts/{session_id}.jsonl`, this
/// derives the OTEL DB path and reads the most recent span for that session,
/// preferring the requested model (the user-facing identifier) and falling back
/// to the resolved response model.
pub fn extract_model_from_copilot_otel_db(
stream_path: &Path,
) -> Result<Option<String>, StreamError> {
let session_id = stream_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if session_id.is_empty() {
return Ok(None);
}

let db_path = match resolve_copilot_otel_db_path(stream_path) {
Some(p) => p,
None => return Ok(None),
};

let conn = match crate::streams::agents::opencode::open_sqlite_readonly(&db_path) {
Ok(c) => c,
Err(_) => return Ok(None),
};

let model = conn
.query_row(
"SELECT request_model, response_model FROM spans \
WHERE chat_session_id = ?1 \
AND (request_model IS NOT NULL OR response_model IS NOT NULL) \
ORDER BY end_time_ms DESC LIMIT 1",
[session_id],
|row| {
let request: Option<String> = row.get(0)?;
let response: Option<String> = row.get(1)?;
Ok(request
.filter(|s| !s.is_empty())
.or(response.filter(|s| !s.is_empty())))
},
)
.ok()
.flatten();

Ok(model)
}

fn extract_model_from_copilot_session_json(path: &Path) -> Result<Option<String>, StreamError> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Expand Down Expand Up @@ -433,6 +517,164 @@ mod tests {
assert_eq!(result, None);
}

/// Build a Copilot VS Code workspace layout in `root` and an `agent-traces.db`
/// OTEL DB at the expected globalStorage location. Returns the transcript path
/// (which need not exist on disk) for the given session id.
fn setup_copilot_otel_workspace(root: &Path, session_id: &str) -> (PathBuf, PathBuf) {
let transcripts_dir = root
.join("User")
.join("workspaceStorage")
.join("hash123")
.join("GitHub.copilot-chat")
.join("transcripts");
std::fs::create_dir_all(&transcripts_dir).unwrap();
let transcript_path = transcripts_dir.join(format!("{session_id}.jsonl"));

let global_storage_dir = root
.join("User")
.join("globalStorage")
.join("github.copilot-chat");
std::fs::create_dir_all(&global_storage_dir).unwrap();
let db_path = global_storage_dir.join("agent-traces.db");

let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE spans (
span_id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, parent_span_id TEXT,
name TEXT NOT NULL, start_time_ms INTEGER NOT NULL, end_time_ms INTEGER NOT NULL,
status_code INTEGER NOT NULL DEFAULT 0, status_message TEXT,
operation_name TEXT, provider_name TEXT, agent_name TEXT, conversation_id TEXT,
request_model TEXT, response_model TEXT,
input_tokens INTEGER, output_tokens INTEGER, cached_tokens INTEGER, reasoning_tokens INTEGER,
tool_name TEXT, tool_call_id TEXT, tool_type TEXT,
chat_session_id TEXT, turn_index INTEGER, ttft_ms REAL
);",
)
.unwrap();
drop(conn);

(transcript_path, db_path)
}

fn insert_otel_span(
db_path: &Path,
span_id: &str,
end_time_ms: i64,
chat_session_id: &str,
request_model: Option<&str>,
response_model: Option<&str>,
) {
let conn = rusqlite::Connection::open(db_path).unwrap();
conn.execute(
"INSERT INTO spans (span_id, trace_id, name, start_time_ms, end_time_ms, \
request_model, response_model, chat_session_id) \
VALUES (?1, 'trace1', 'chat', ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
span_id,
end_time_ms - 1000,
end_time_ms,
request_model,
response_model,
chat_session_id,
],
)
.unwrap();
}

#[test]
fn test_extract_model_copilot_otel_prefers_request_model() {
let dir = tempfile::TempDir::new().unwrap();
let (transcript_path, db_path) = setup_copilot_otel_workspace(dir.path(), "sess-otel-1");
insert_otel_span(
&db_path,
"span1",
1000,
"sess-otel-1",
Some("claude-sonnet-4"),
Some("claude-sonnet-4-20250514"),
);

let result = extract_model_from_copilot_otel_db(&transcript_path).unwrap();
assert_eq!(result, Some("claude-sonnet-4".to_string()));
}

#[test]
fn test_extract_model_copilot_otel_falls_back_to_response_model() {
let dir = tempfile::TempDir::new().unwrap();
let (transcript_path, db_path) = setup_copilot_otel_workspace(dir.path(), "sess-otel-2");
insert_otel_span(
&db_path,
"span1",
1000,
"sess-otel-2",
None,
Some("gpt-4.1"),
);

let result = extract_model_from_copilot_otel_db(&transcript_path).unwrap();
assert_eq!(result, Some("gpt-4.1".to_string()));
}

#[test]
fn test_extract_model_copilot_otel_ignores_empty_response_model() {
let dir = tempfile::TempDir::new().unwrap();
let (transcript_path, db_path) =
setup_copilot_otel_workspace(dir.path(), "sess-otel-empty");
insert_otel_span(&db_path, "span1", 1000, "sess-otel-empty", None, Some(""));

let result = extract_model_from_copilot_otel_db(&transcript_path).unwrap();
assert_eq!(result, None);
}

#[test]
fn test_extract_model_copilot_otel_uses_most_recent_span() {
let dir = tempfile::TempDir::new().unwrap();
let (transcript_path, db_path) = setup_copilot_otel_workspace(dir.path(), "sess-otel-3");
insert_otel_span(
&db_path,
"span1",
1000,
"sess-otel-3",
Some("gpt-4.1"),
None,
);
insert_otel_span(
&db_path,
"span2",
5000,
"sess-otel-3",
Some("claude-sonnet-4"),
None,
);

let result = extract_model_from_copilot_otel_db(&transcript_path).unwrap();
assert_eq!(result, Some("claude-sonnet-4".to_string()));
}

#[test]
fn test_extract_model_copilot_otel_ignores_other_sessions() {
let dir = tempfile::TempDir::new().unwrap();
let (transcript_path, db_path) = setup_copilot_otel_workspace(dir.path(), "sess-otel-4");
insert_otel_span(
&db_path,
"span1",
1000,
"other-session",
Some("gpt-4.1"),
None,
);

let result = extract_model_from_copilot_otel_db(&transcript_path).unwrap();
assert_eq!(result, None);
}

#[test]
fn test_extract_model_copilot_otel_missing_db() {
let path = PathBuf::from("/nonexistent/transcripts/fake-session.jsonl");
let result = extract_model_from_copilot_otel_db(&path).unwrap();
assert_eq!(result, None);
}

#[test]
fn test_extract_model_head_fallback_for_large_file() {
use std::io::Write;
Expand Down