use std::collections::HashMap;
use std::path::PathBuf;
use super::types::{AgentKind, SessionFile, TimeFilter, ToolInput, ToolInvocation, ToolResult};
use super::SessionProvider;
const MAX_SESSION_SIZE: u64 = 100 * 1024 * 1024;
pub(crate) struct CopilotCliProvider {
sessions_dir: PathBuf,
}
impl CopilotCliProvider {
pub(crate) fn detect() -> Option<Self> {
let sessions_dir = if let Ok(override_dir) = std::env::var("SKIM_COPILOT_DIR") {
PathBuf::from(override_dir)
} else {
dirs::home_dir()?.join(".copilot").join("session-state")
};
if sessions_dir.is_dir() {
Some(Self { sessions_dir })
} else {
None
}
}
}
impl SessionProvider for CopilotCliProvider {
fn agent_kind(&self) -> AgentKind {
AgentKind::CopilotCli
}
fn find_sessions(&self, filter: &TimeFilter) -> anyhow::Result<Vec<SessionFile>> {
let mut sessions = Vec::new();
let canonical_root = self
.sessions_dir
.canonicalize()
.unwrap_or_else(|_| self.sessions_dir.clone());
let entries = std::fs::read_dir(&self.sessions_dir)?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Ok(canonical_path) = path.canonicalize() {
if !canonical_path.starts_with(&canonical_root) {
eprintln!(
"warning: skipping file outside session dir: {}",
path.display()
);
continue;
}
}
let modified = match std::fs::metadata(&path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(e) => {
eprintln!(
"warning: could not read metadata for {}: {}",
path.display(),
e
);
continue;
}
};
if let Some(since) = filter.since {
if modified < since {
continue;
}
}
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
sessions.push(SessionFile {
path,
modified,
agent: AgentKind::CopilotCli,
session_id,
});
}
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
if filter.latest_only {
sessions.truncate(1);
}
Ok(sessions)
}
fn parse_session(&self, file: &SessionFile) -> anyhow::Result<Vec<ToolInvocation>> {
let file_size = std::fs::metadata(&file.path)?.len();
if file_size > MAX_SESSION_SIZE {
anyhow::bail!(
"session file too large ({:.1} MB, limit {:.0} MB): {}",
file_size as f64 / (1024.0 * 1024.0),
MAX_SESSION_SIZE as f64 / (1024.0 * 1024.0),
file.path.display()
);
}
let content = std::fs::read_to_string(&file.path)?;
parse_copilot_jsonl(&content, &file.session_id)
}
}
fn skip_yaml_header(content: &str) -> &str {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return content;
}
let after_first = match trimmed.strip_prefix("---") {
Some(rest) => rest.trim_start_matches(['\r', ' ', '\t']),
None => return content,
};
let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
if let Some(end_idx) = after_first.find("\n---") {
let rest_start = end_idx + 4; if rest_start < after_first.len() {
&after_first[rest_start..]
} else {
""
}
} else {
content
}
}
fn parse_copilot_jsonl(content: &str, session_id: &str) -> anyhow::Result<Vec<ToolInvocation>> {
let jsonl_body = skip_yaml_header(content);
let mut invocations = Vec::new();
let mut pending: HashMap<String, usize> = HashMap::new();
for line in jsonl_body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, };
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
let timestamp = json
.get("timestamp")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
match event_type {
"tool_use" => {
let tool_id = json
.get("id")
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let tool_name = json
.get("toolName")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let tool_args = json
.get("toolArgs")
.cloned()
.unwrap_or(serde_json::Value::Null);
let input = parse_copilot_tool_input(&tool_name, &tool_args);
let idx = invocations.len();
invocations.push(ToolInvocation {
tool_name: tool_name.clone(),
input,
timestamp,
session_id: session_id.to_string(),
agent: AgentKind::CopilotCli,
result: None,
});
if !tool_id.is_empty() {
pending.insert(tool_id, idx);
}
}
"tool_result" => {
let tool_use_id = json
.get("toolUseId")
.and_then(|id| id.as_str())
.unwrap_or("");
if let Some(&idx) = pending.get(tool_use_id) {
let result_content = json
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let result_type = json
.get("resultType")
.and_then(|r| r.as_str())
.unwrap_or("success");
let is_error = result_type == "error";
invocations[idx].result = Some(ToolResult {
content: result_content,
is_error,
});
pending.remove(tool_use_id);
}
}
_ => {} }
}
Ok(invocations)
}
fn parse_copilot_tool_input(tool_name: &str, args: &serde_json::Value) -> ToolInput {
match tool_name {
"bash" => {
let command = args
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
ToolInput::Bash { command }
}
"readFile" => {
let file_path = args
.get("path")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Read { file_path }
}
"writeFile" => {
let file_path = args
.get("path")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Write { file_path }
}
"editFile" => {
let file_path = args
.get("path")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
ToolInput::Edit { file_path }
}
_ => ToolInput::Other {
tool_name: tool_name.to_string(),
raw: args.clone(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_jsonl_without_yaml_header() {
let content = concat!(
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "cargo test"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#,
"\n",
r#"{ "type": "tool_result", "toolUseId": "t-001", "resultType": "success", "content": "ok", "timestamp": "2024-06-15T10:01:05Z" }"#,
);
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].tool_name, "bash");
assert!(matches!(
&invocations[0].input,
ToolInput::Bash { command } if command == "cargo test"
));
assert!(invocations[0].result.is_some());
assert_eq!(invocations[0].result.as_ref().unwrap().content, "ok");
assert!(!invocations[0].result.as_ref().unwrap().is_error);
}
#[test]
fn test_parse_jsonl_with_yaml_header() {
let content = concat!(
"---\n",
"model: gpt-4o\n",
"session_start: \"2024-06-15T10:00:00Z\"\n",
"---\n",
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "ls"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#,
"\n",
);
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].tool_name, "bash");
assert!(matches!(
&invocations[0].input,
ToolInput::Bash { command } if command == "ls"
));
}
#[test]
fn test_correlate_tool_result() {
let content = concat!(
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "echo hi"}, "id": "t-010", "timestamp": "2024-06-15T10:01:00Z" }"#,
"\n",
r#"{ "type": "tool_result", "toolUseId": "t-010", "resultType": "success", "content": "hi", "timestamp": "2024-06-15T10:01:01Z" }"#,
);
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].result.is_some());
assert_eq!(invocations[0].result.as_ref().unwrap().content, "hi");
assert!(!invocations[0].result.as_ref().unwrap().is_error);
}
#[test]
fn test_result_type_error() {
let content = concat!(
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "false"}, "id": "t-020", "timestamp": "2024-06-15T10:01:00Z" }"#,
"\n",
r#"{ "type": "tool_result", "toolUseId": "t-020", "resultType": "error", "content": "command failed", "timestamp": "2024-06-15T10:01:01Z" }"#,
);
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].result.as_ref().unwrap().is_error);
assert_eq!(
invocations[0].result.as_ref().unwrap().content,
"command failed"
);
}
#[test]
fn test_skip_malformed_lines() {
let content = "not json\n{}\n";
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 0);
}
#[test]
fn test_empty_input() {
let invocations = parse_copilot_jsonl("", "sess1").unwrap();
assert_eq!(invocations.len(), 0);
}
#[test]
fn test_multiple_tools() {
let content = concat!(
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "cargo test"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#,
"\n",
r#"{ "type": "tool_result", "toolUseId": "t-001", "resultType": "success", "content": "ok", "timestamp": "2024-06-15T10:01:05Z" }"#,
"\n",
r#"{ "type": "tool_use", "toolName": "readFile", "toolArgs": {"path": "/tmp/main.rs"}, "id": "t-002", "timestamp": "2024-06-15T10:02:00Z" }"#,
"\n",
r#"{ "type": "tool_result", "toolUseId": "t-002", "resultType": "success", "content": "fn main() {}", "timestamp": "2024-06-15T10:02:01Z" }"#,
"\n",
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "git status"}, "id": "t-003", "timestamp": "2024-06-15T10:03:00Z" }"#,
);
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 3);
assert_eq!(invocations[0].tool_name, "bash");
assert!(invocations[0].result.is_some());
assert_eq!(invocations[1].tool_name, "readFile");
assert!(
matches!(&invocations[1].input, ToolInput::Read { file_path } if file_path == "/tmp/main.rs")
);
assert!(invocations[1].result.is_some());
assert_eq!(invocations[2].tool_name, "bash");
assert!(invocations[2].result.is_none());
}
#[test]
fn test_skip_yaml_header() {
let content = concat!(
"---\n",
"model: gpt-4o\n",
"session_start: \"2024-06-15T10:00:00Z\"\n",
"project: \"/home/user/myproject\"\n",
"---\n",
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "echo test"}, "id": "t-100", "timestamp": "2024-06-15T10:05:00Z" }"#,
);
let body = skip_yaml_header(content);
assert!(!body.is_empty());
assert!(!body.contains("model: gpt-4o"));
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
}
#[test]
fn test_tool_input_bash() {
let args = serde_json::json!({"command": "cargo build"});
let input = parse_copilot_tool_input("bash", &args);
assert!(matches!(input, ToolInput::Bash { command } if command == "cargo build"));
}
#[test]
fn test_tool_input_read_file() {
let args = serde_json::json!({"path": "/tmp/test.rs"});
let input = parse_copilot_tool_input("readFile", &args);
assert!(matches!(input, ToolInput::Read { file_path } if file_path == "/tmp/test.rs"));
}
#[test]
fn test_tool_input_write_file() {
let args = serde_json::json!({"path": "/tmp/out.rs"});
let input = parse_copilot_tool_input("writeFile", &args);
assert!(matches!(input, ToolInput::Write { file_path } if file_path == "/tmp/out.rs"));
}
#[test]
fn test_tool_input_edit_file() {
let args = serde_json::json!({"path": "/tmp/edit.rs"});
let input = parse_copilot_tool_input("editFile", &args);
assert!(matches!(input, ToolInput::Edit { file_path } if file_path == "/tmp/edit.rs"));
}
#[test]
fn test_tool_input_unknown() {
let args = serde_json::json!({"foo": "bar"});
let input = parse_copilot_tool_input("unknownTool", &args);
assert!(matches!(input, ToolInput::Other { tool_name, .. } if tool_name == "unknownTool"));
}
#[test]
fn test_agent_kind_is_copilot() {
let content = r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "ls"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#;
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].agent, AgentKind::CopilotCli);
}
#[test]
fn test_session_id_propagation() {
let content = r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "ls"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#;
let invocations = parse_copilot_jsonl(content, "my-session-42").unwrap();
assert_eq!(invocations[0].session_id, "my-session-42");
}
#[test]
fn test_timestamp_propagation() {
let content = r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "ls"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#;
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations[0].timestamp, "2024-06-15T10:01:00Z");
}
#[test]
fn test_yaml_header_no_closing_delimiter() {
let content = concat!(
"---\n",
"model: gpt-4o\n",
r#"{ "type": "tool_use", "toolName": "bash", "toolArgs": {"command": "ls"}, "id": "t-001", "timestamp": "2024-06-15T10:01:00Z" }"#,
);
let body = skip_yaml_header(content);
assert_eq!(body, content);
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 1);
}
#[test]
fn test_uncorrelated_result_ignored() {
let content = r#"{ "type": "tool_result", "toolUseId": "nonexistent", "resultType": "success", "content": "orphan", "timestamp": "2024-06-15T10:01:00Z" }"#;
let invocations = parse_copilot_jsonl(content, "sess1").unwrap();
assert_eq!(invocations.len(), 0);
}
}