Skip to main content

skiagram_core/adapters/
copilot.rs

1//! GitHub Copilot CLI adapter — STRUCTURAL ONLY (v0.4).
2//!
3//! Reads `~/.copilot/session-state/` (READ-ONLY) in two layouts seen on real
4//! installs:
5//! - current: `~/.copilot/session-state/<session-uuid>/events.jsonl`
6//! - older flat: `~/.copilot/session-state/<session-uuid>.jsonl`
7//!
8//! Both are discovered. `$COPILOT_HOME` overrides the `~/.copilot` root (the
9//! integration test points it at fixtures; `directories::BaseDirs` does not read
10//! `$HOME` on Windows, so an explicit override is required for testability —
11//! mirrors Claude Code's `CLAUDE_CONFIG_DIR`).
12//!
13//! ## Honesty: Copilot does NOT log per-request token usage (CLAUDE.md §8.5)
14//!
15//! Copilot's event log records conversation STRUCTURE — sessions, model choices
16//! and switches, turns, tool requests/results, reasoning presence, and content
17//! sizes — but it does **not** record per-request billing token counts
18//! (input/output/cache). The only token-ish fields are `session.truncation`
19//! (context-window limits, not billing) and a `responseTokenLimit` in tool
20//! telemetry (a cap, not a count). Neither is billable usage.
21//!
22//! Therefore every [`Event::usage`] this adapter emits is `None` — UNKNOWN, not
23//! zero. We surface the structure and leave spend explicitly unknown rather than
24//! invent or estimate it. (`summary` will show 0 priced tokens but a populated
25//! `by_tool` — that is the correct, honest outcome.)
26//!
27//! Schema (VERIFIED on real local files, 2026-06-16 — ≥3 nested sessions plus a
28//! flat one were present under `~/.copilot/session-state/`; their content is
29//! read-only and contains real prompts, so it is not reproduced here. Every line
30//! is `{"type", "data", "id", "timestamp", "parentId"}`):
31//! - `session.start` — `data.{sessionId, copilotVersion, startTime, selectedModel,
32//!   context.cwd}`. Source of session id / project (cwd basename) / start model.
33//! - `session.resume` — `data.{resumeTime, eventCount, context.cwd}`.
34//! - `session.model_change` — `data.{previousModel, newModel}`. Tracked toward the
35//!   chosen `Session.model` (most-frequently-seen wins; see below).
36//! - `user.message` — `data.content` (+ `transformedContent`, `attachments[]`).
37//! - `assistant.turn_start` / `assistant.turn_end` — turn boundaries (mapped to
38//!   `System` markers; kept minimal).
39//! - `assistant.message` — `data.{messageId, content, toolRequests[], reasoningText,
40//!   reasoningOpaque}`. Each `toolRequests[]` -> a [`ToolCall`]; reasoning presence
41//!   sets `has_thinking`.
42//! - `tool.execution_start` — `data.{toolCallId, toolName, arguments}`.
43//! - `tool.execution_complete` — `data.{toolCallId, success, result, model}` ->
44//!   `ToolResult` linked by `tool_use_id = toolCallId`.
45//! - `session.truncation` — context-window fill info, NOT per-request billing.
46
47use 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
58/// GitHub Copilot CLI (`~/.copilot`).
59pub struct Copilot;
60
61/// Data root: `$COPILOT_HOME` when set (tests use it to point at fixtures), else
62/// `~/.copilot` resolved via the `directories` crate — never a hardcoded `~`.
63/// An explicit override is required because `directories::BaseDirs` ignores
64/// `$HOME` on Windows (so the integration test could not otherwise relocate it).
65fn 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        // Walk the tree so BOTH `<uuid>/events.jsonl` (current) and flat
87        // `<uuid>.jsonl` (older) layouts are picked up. Any other `*.jsonl`
88        // Copilot writes under here is harmless to include — parse is lenient.
89        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 lives INSIDE the file (`session.start` cwd), not in the
107                // path, so it is resolved during `parse`, not here.
108                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            // Copilot has no sub-agent transcripts; spend is never re-parented.
131            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        // BTreeMap so model tie-breaking is deterministic.
139        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                // Bookkeeping / boundary lines that carry no token spend and no
169                // tool linkage we need. Kept explicit so the skip stat means
170                // *unexpected*, not "merely uninteresting".
171                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        // Most-frequently-seen model wins (mirrors Claude Code). `selectedModel`
192        // and every `newModel` each contribute one tally. Deterministic: iterating
193        // the BTreeMap ascending, `max_by_key` keeps the lexicographically-largest
194        // model id among any count-tie.
195        session.model = model_counts
196            .into_iter()
197            .max_by_key(|(_, count)| *count)
198            .map(|(model, _)| model);
199        Ok(session)
200    }
201}
202
203/// Session id = parent dir name for `<uuid>/events.jsonl`, else the file stem for
204/// flat `<uuid>.jsonl`.
205fn 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
222/// Display-only first non-empty line, capped at 80 chars.
223fn 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
229/// `/home/dev/acme-app` (or `C:\…\acme-app`) -> `acme-app`. Project label only.
230fn 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    // A System marker so the session start is visible on the timeline; usage is
256    // unknown (Copilot logs none).
257    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    // Reasoning presence (text and/or opaque/encrypted) => has_thinking. Only the
346    // plain `reasoningText` is measurable; opaque reasoning is unmeasurable
347    // (mirrors Claude Code's encrypted-thinking handling).
348    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        // Input size = serialized `arguments`, matching how Claude Code sizes a
356        // tool call's input (a proxy for what it weighs in the window).
357        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    // Copilot does not record assistant model per message in the cases sampled,
371    // but if a future build adds one, fold it into the model tally too.
372    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        // HONEST: Copilot logs no per-request usage (§8.5) — unknown, not zero.
385        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    // Serialized result size = what this result weighs in the context window —
404    // input for context-bloat analysis (mirrors Claude Code's tool-result sizing).
405    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        // The matching tool call's id, so results link back to their call.
418        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// ---- raw line shapes (lenient: unknown fields ignored everywhere) ----
429
430/// One Copilot event line: `{type, data, id, timestamp, parentId}`.
431#[derive(Deserialize)]
432struct RawLine {
433    #[serde(rename = "type")]
434    kind: Option<String>,
435    timestamp: Option<String>,
436    data: Option<RawData>,
437}
438
439/// The `data` object. A single flat struct covers every `type` we read; absent
440/// fields simply stay `None` (lenient — one shape, many event kinds).
441#[derive(Deserialize, Default)]
442#[serde(default)]
443struct RawData {
444    // session.start
445    #[serde(rename = "selectedModel")]
446    selected_model: Option<String>,
447    #[serde(rename = "startTime")]
448    start_time: Option<String>,
449    context: Option<RawContext>,
450    // session.resume
451    #[serde(rename = "resumeTime")]
452    resume_time: Option<String>,
453    // session.model_change
454    #[serde(rename = "newModel")]
455    new_model: Option<String>,
456    // user.message / assistant.message
457    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    // tool.execution_complete / tool.execution_start
465    #[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    /// Present on some assistant / tool-completion lines.
471    model: Option<String>,
472}
473
474#[derive(Deserialize, Default)]
475#[serde(default)]
476struct RawContext {
477    cwd: Option<String>,
478}
479
480/// One entry of `assistant.message`'s `toolRequests[]`.
481#[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        // content_chars includes the visible answer + reasoning text length.
541        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}