1use std::collections::BTreeMap;
48use std::fs::File;
49use std::io::{BufRead, BufReader};
50use std::path::{Path, PathBuf};
51
52use serde::Deserialize;
53
54use crate::adapters::Adapter;
55use crate::error::CoreError;
56use crate::model::{Event, EventKind, Session, SessionRef, ToolCall};
57
58pub struct Copilot;
60
61fn copilot_dir() -> Option<PathBuf> {
66 match std::env::var("COPILOT_HOME") {
67 Ok(dir) if !dir.trim().is_empty() => Some(PathBuf::from(dir)),
68 _ => directories::BaseDirs::new().map(|b| b.home_dir().join(".copilot")),
69 }
70}
71
72impl Adapter for Copilot {
73 fn id(&self) -> &'static str {
74 "copilot"
75 }
76
77 fn detect(&self) -> bool {
78 copilot_dir().is_some_and(|d| d.join("session-state").is_dir())
79 }
80
81 fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
82 let root = copilot_dir()
83 .ok_or_else(|| anyhow::anyhow!("could not determine the home directory"))?
84 .join("session-state");
85 let mut refs = Vec::new();
86 for entry in walkdir::WalkDir::new(&root).follow_links(false) {
90 let entry = match entry {
91 Ok(e) => e,
92 Err(e) => {
93 tracing::debug!("skipping unreadable directory entry: {e}");
94 continue;
95 }
96 };
97 if !entry.file_type().is_file()
98 || entry.path().extension().and_then(|x| x.to_str()) != Some("jsonl")
99 {
100 continue;
101 }
102 let meta = entry.metadata().ok();
103 refs.push(SessionRef {
104 path: entry.path().to_path_buf(),
105 agent: self.id().to_string(),
106 project: None,
109 size_bytes: meta.as_ref().map_or(0, |m| m.len()),
110 modified: meta
111 .and_then(|m| m.modified().ok())
112 .and_then(|t| jiff::Timestamp::try_from(t).ok()),
113 });
114 }
115 refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
116 Ok(refs)
117 }
118
119 fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
120 let file = File::open(&r.path).map_err(|source| CoreError::Io {
121 path: r.path.clone(),
122 source,
123 })?;
124
125 let mut session = Session {
126 id: session_id_for(&r.path),
127 agent: self.id().to_string(),
128 project: r.project.clone(),
129 model: None,
130 parent_session: None,
132 started_at: None,
133 ended_at: None,
134 events: Vec::new(),
135 sub_agents: Vec::new(),
136 skipped_lines: 0,
137 };
138 let mut model_counts: BTreeMap<String, u64> = BTreeMap::new();
140
141 for (idx, line) in BufReader::new(file).lines().enumerate() {
142 let line = match line {
143 Ok(l) => l,
144 Err(e) => {
145 session.skipped_lines += 1;
146 tracing::debug!("{}:{}: unreadable line: {e}", r.path.display(), idx + 1);
147 continue;
148 }
149 };
150 if line.trim().is_empty() {
151 continue;
152 }
153 let raw: RawLine = match serde_json::from_str(&line) {
154 Ok(v) => v,
155 Err(e) => {
156 session.skipped_lines += 1;
157 tracing::debug!("{}:{}: unparseable JSON: {e}", r.path.display(), idx + 1);
158 continue;
159 }
160 };
161 match raw.kind.as_deref() {
162 Some("session.start") => apply_session_start(&raw, &mut session, &mut model_counts),
163 Some("session.resume") => apply_session_resume(&raw, &mut session),
164 Some("session.model_change") => apply_model_change(&raw, &mut model_counts),
165 Some("user.message") => push_user(&raw, &mut session),
166 Some("assistant.message") => push_assistant(&raw, &mut session, &mut model_counts),
167 Some("tool.execution_complete") => push_tool_result(&raw, &mut session),
168 Some(
172 "assistant.turn_start"
173 | "assistant.turn_end"
174 | "tool.execution_start"
175 | "session.truncation",
176 ) => {}
177 other => {
178 session.skipped_lines += 1;
179 tracing::debug!(
180 "{}:{}: unknown line type {:?}",
181 r.path.display(),
182 idx + 1,
183 other
184 );
185 }
186 }
187 }
188
189 session.started_at = session.events.iter().filter_map(|e| e.ts).min();
190 session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
191 session.model = model_counts
196 .into_iter()
197 .max_by_key(|(_, count)| *count)
198 .map(|(model, _)| model);
199 Ok(session)
200 }
201}
202
203fn session_id_for(path: &Path) -> String {
206 let stem = path
207 .file_stem()
208 .map(|s| s.to_string_lossy().into_owned())
209 .unwrap_or_else(|| path.to_string_lossy().into_owned());
210 if stem == "events" {
211 if let Some(parent) = path.parent().and_then(|p| p.file_name()) {
212 return parent.to_string_lossy().into_owned();
213 }
214 }
215 stem
216}
217
218fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
219 raw.as_deref().and_then(|t| t.parse().ok())
220}
221
222fn snippet(text: &str) -> Option<String> {
224 let first = text.lines().find(|l| !l.trim().is_empty())?;
225 let s: String = first.trim().chars().take(80).collect();
226 (!s.is_empty()).then_some(s)
227}
228
229fn cwd_basename(cwd: &str) -> Option<String> {
231 let trimmed = cwd.trim_end_matches(['/', '\\']);
232 let base = trimmed
233 .rsplit(['/', '\\'])
234 .find(|s| !s.is_empty())
235 .unwrap_or(trimmed);
236 (!base.is_empty()).then(|| base.to_string())
237}
238
239fn apply_session_start(
240 raw: &RawLine,
241 session: &mut Session,
242 model_counts: &mut BTreeMap<String, u64>,
243) {
244 let Some(data) = &raw.data else { return };
245 if session.project.is_none() {
246 if let Some(cwd) = data.context.as_ref().and_then(|c| c.cwd.as_deref()) {
247 session.project = cwd_basename(cwd);
248 }
249 }
250 if let Some(model) = data.selected_model.as_deref() {
251 if !model.is_empty() {
252 *model_counts.entry(model.to_string()).or_default() += 1;
253 }
254 }
255 session.events.push(Event {
258 kind: EventKind::System,
259 ts: parse_ts(&raw.timestamp).or_else(|| parse_ts(&data.start_time)),
260 request_id: None,
261 model: data.selected_model.clone(),
262 usage: None,
263 tool_calls: Vec::new(),
264 sidechain: false,
265 content_summary: Some("session start".to_string()),
266 content_chars: 0,
267 thinking_chars: 0,
268 has_thinking: false,
269 tool_use_id: None,
270 attachment_kind: None,
271 item_count: 0,
272 });
273}
274
275fn apply_session_resume(raw: &RawLine, session: &mut Session) {
276 let Some(data) = &raw.data else { return };
277 if session.project.is_none() {
278 if let Some(cwd) = data.context.as_ref().and_then(|c| c.cwd.as_deref()) {
279 session.project = cwd_basename(cwd);
280 }
281 }
282 session.events.push(Event {
283 kind: EventKind::System,
284 ts: parse_ts(&raw.timestamp).or_else(|| parse_ts(&data.resume_time)),
285 request_id: None,
286 model: None,
287 usage: None,
288 tool_calls: Vec::new(),
289 sidechain: false,
290 content_summary: Some("session resume".to_string()),
291 content_chars: 0,
292 thinking_chars: 0,
293 has_thinking: false,
294 tool_use_id: None,
295 attachment_kind: None,
296 item_count: 0,
297 });
298}
299
300fn apply_model_change(raw: &RawLine, model_counts: &mut BTreeMap<String, u64>) {
301 if let Some(model) = raw
302 .data
303 .as_ref()
304 .and_then(|d| d.new_model.as_deref())
305 .filter(|m| !m.is_empty())
306 {
307 *model_counts.entry(model.to_string()).or_default() += 1;
308 }
309}
310
311fn push_user(raw: &RawLine, session: &mut Session) {
312 let content = raw
313 .data
314 .as_ref()
315 .and_then(|d| d.content.as_deref())
316 .unwrap_or("");
317 session.events.push(Event {
318 kind: EventKind::User,
319 ts: parse_ts(&raw.timestamp),
320 request_id: None,
321 model: None,
322 usage: None,
323 tool_calls: Vec::new(),
324 sidechain: false,
325 content_summary: snippet(content),
326 content_chars: content.chars().count() as u64,
327 thinking_chars: 0,
328 has_thinking: false,
329 tool_use_id: None,
330 attachment_kind: None,
331 item_count: 0,
332 });
333}
334
335fn push_assistant(raw: &RawLine, session: &mut Session, model_counts: &mut BTreeMap<String, u64>) {
336 let Some(data) = &raw.data else {
337 session.skipped_lines += 1;
338 return;
339 };
340 let ts = parse_ts(&raw.timestamp);
341
342 let content = data.content.as_deref().unwrap_or("");
343 let mut content_chars = content.chars().count() as u64;
344
345 let reasoning_text = data.reasoning_text.as_deref().unwrap_or("");
349 let has_thinking = !reasoning_text.is_empty() || data.reasoning_opaque.is_some();
350 content_chars += reasoning_text.chars().count() as u64;
351
352 let mut tool_calls = Vec::new();
353 for req in &data.tool_requests {
354 let name = req.name.clone().unwrap_or_else(|| "unknown".to_string());
355 let input_bytes = req
358 .arguments
359 .as_ref()
360 .map_or(0, |a| a.to_string().len() as u64);
361 content_chars += input_bytes;
362 tool_calls.push(ToolCall {
363 server: ToolCall::server_from_name(&name),
364 id: req.tool_call_id.clone().unwrap_or_default(),
365 name,
366 input_bytes,
367 });
368 }
369
370 let model = data.model.clone();
373 if let Some(m) = &model {
374 if !m.is_empty() {
375 *model_counts.entry(m.clone()).or_default() += 1;
376 }
377 }
378
379 session.events.push(Event {
380 kind: EventKind::Assistant,
381 ts,
382 request_id: None,
383 model,
384 usage: None,
386 tool_calls,
387 sidechain: false,
388 content_summary: snippet(content),
389 content_chars,
390 thinking_chars: reasoning_text.chars().count() as u64,
391 has_thinking,
392 tool_use_id: None,
393 attachment_kind: None,
394 item_count: 0,
395 });
396}
397
398fn push_tool_result(raw: &RawLine, session: &mut Session) {
399 let Some(data) = &raw.data else {
400 session.skipped_lines += 1;
401 return;
402 };
403 let content_chars = data
406 .result
407 .as_ref()
408 .map_or(0, |v| v.to_string().len() as u64);
409 session.events.push(Event {
410 kind: EventKind::ToolResult,
411 ts: parse_ts(&raw.timestamp),
412 request_id: None,
413 model: data.model.clone(),
414 usage: None,
415 tool_calls: Vec::new(),
416 sidechain: false,
417 content_summary: data.tool_name.clone().or_else(|| data.tool_call_id.clone()),
419 content_chars,
420 thinking_chars: 0,
421 has_thinking: false,
422 tool_use_id: data.tool_call_id.clone(),
423 attachment_kind: None,
424 item_count: 0,
425 });
426}
427
428#[derive(Deserialize)]
432struct RawLine {
433 #[serde(rename = "type")]
434 kind: Option<String>,
435 timestamp: Option<String>,
436 data: Option<RawData>,
437}
438
439#[derive(Deserialize, Default)]
442#[serde(default)]
443struct RawData {
444 #[serde(rename = "selectedModel")]
446 selected_model: Option<String>,
447 #[serde(rename = "startTime")]
448 start_time: Option<String>,
449 context: Option<RawContext>,
450 #[serde(rename = "resumeTime")]
452 resume_time: Option<String>,
453 #[serde(rename = "newModel")]
455 new_model: Option<String>,
456 content: Option<String>,
458 #[serde(rename = "reasoningText")]
459 reasoning_text: Option<String>,
460 #[serde(rename = "reasoningOpaque")]
461 reasoning_opaque: Option<serde_json::Value>,
462 #[serde(rename = "toolRequests")]
463 tool_requests: Vec<RawToolRequest>,
464 #[serde(rename = "toolCallId")]
466 tool_call_id: Option<String>,
467 #[serde(rename = "toolName")]
468 tool_name: Option<String>,
469 result: Option<serde_json::Value>,
470 model: Option<String>,
472}
473
474#[derive(Deserialize, Default)]
475#[serde(default)]
476struct RawContext {
477 cwd: Option<String>,
478}
479
480#[derive(Deserialize, Default)]
482#[serde(default)]
483struct RawToolRequest {
484 #[serde(rename = "toolCallId")]
485 tool_call_id: Option<String>,
486 name: Option<String>,
487 arguments: Option<serde_json::Value>,
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 fn empty_session() -> Session {
495 Session {
496 id: "s".into(),
497 agent: "copilot".into(),
498 project: None,
499 model: None,
500 parent_session: None,
501 started_at: None,
502 ended_at: None,
503 events: Vec::new(),
504 sub_agents: Vec::new(),
505 skipped_lines: 0,
506 }
507 }
508
509 #[test]
510 fn tool_request_maps_to_tool_call_with_server_and_no_usage() {
511 let line = r#"{"type":"assistant.message","timestamp":"2026-06-16T10:00:00Z","data":{"messageId":"m1","content":"on it","toolRequests":[{"toolCallId":"call_1","name":"mcp__github__search_issues","arguments":{"query":"x"},"type":"function"}]}}"#;
512 let raw: RawLine = serde_json::from_str(line).unwrap();
513 let mut session = empty_session();
514 push_assistant(&raw, &mut session, &mut BTreeMap::new());
515
516 assert_eq!(session.events.len(), 1);
517 let ev = &session.events[0];
518 assert_eq!(ev.kind, EventKind::Assistant);
519 assert!(
520 ev.usage.is_none(),
521 "Copilot logs no per-request usage (§8.5)"
522 );
523 assert_eq!(ev.tool_calls.len(), 1);
524 assert_eq!(ev.tool_calls[0].id, "call_1");
525 assert_eq!(ev.tool_calls[0].name, "mcp__github__search_issues");
526 assert_eq!(ev.tool_calls[0].server.as_deref(), Some("github"));
527 assert!(ev.tool_calls[0].input_bytes > 0);
528 }
529
530 #[test]
531 fn reasoning_sets_has_thinking_but_not_usage() {
532 let line = r#"{"type":"assistant.message","timestamp":"2026-06-16T10:00:01Z","data":{"messageId":"m2","content":"answer","reasoningText":"let me think about this"}}"#;
533 let raw: RawLine = serde_json::from_str(line).unwrap();
534 let mut session = empty_session();
535 push_assistant(&raw, &mut session, &mut BTreeMap::new());
536
537 let ev = &session.events[0];
538 assert!(ev.has_thinking, "reasoningText must set has_thinking");
539 assert!(ev.usage.is_none());
540 assert_eq!(
542 ev.content_chars,
543 "answer".len() as u64 + "let me think about this".len() as u64
544 );
545 }
546
547 #[test]
548 fn opaque_reasoning_alone_sets_has_thinking() {
549 let line = r#"{"type":"assistant.message","timestamp":"2026-06-16T10:00:02Z","data":{"messageId":"m3","content":"hi","reasoningOpaque":"BASE64=="}}"#;
550 let raw: RawLine = serde_json::from_str(line).unwrap();
551 let mut session = empty_session();
552 push_assistant(&raw, &mut session, &mut BTreeMap::new());
553 assert!(session.events[0].has_thinking);
554 }
555
556 #[test]
557 fn tool_result_links_by_tool_call_id_and_sizes_result() {
558 let line = r#"{"type":"tool.execution_complete","timestamp":"2026-06-16T10:00:03Z","data":{"toolCallId":"call_1","toolName":"bash","success":true,"result":{"content":"ok"}}}"#;
559 let raw: RawLine = serde_json::from_str(line).unwrap();
560 let mut session = empty_session();
561 push_tool_result(&raw, &mut session);
562
563 let ev = &session.events[0];
564 assert_eq!(ev.kind, EventKind::ToolResult);
565 assert!(ev.usage.is_none());
566 assert!(ev.content_chars > 0, "serialized result has size");
567 }
568
569 #[test]
570 fn session_id_prefers_parent_dir_for_nested_events_file() {
571 let nested = Path::new("session-state/abc-123/events.jsonl");
572 assert_eq!(session_id_for(nested), "abc-123");
573 let flat = Path::new("session-state/def-456.jsonl");
574 assert_eq!(session_id_for(flat), "def-456");
575 }
576
577 #[test]
578 fn cwd_basename_handles_both_separators() {
579 assert_eq!(
580 cwd_basename("/home/dev/acme-app").as_deref(),
581 Some("acme-app")
582 );
583 assert_eq!(
584 cwd_basename("C:\\Users\\dev\\acme-app\\").as_deref(),
585 Some("acme-app")
586 );
587 }
588
589 #[test]
590 fn model_change_tallies_new_model() {
591 let line = r#"{"type":"session.model_change","timestamp":"2026-06-16T10:00:04Z","data":{"previousModel":"gpt-a","newModel":"gpt-b"}}"#;
592 let raw: RawLine = serde_json::from_str(line).unwrap();
593 let mut counts = BTreeMap::new();
594 apply_model_change(&raw, &mut counts);
595 assert_eq!(counts.get("gpt-b"), Some(&1));
596 assert!(
597 !counts.contains_key("gpt-a"),
598 "only the new model is tallied"
599 );
600 }
601}