Skip to main content

toolpath_copilot/
types.rs

1//! Typed model of the Copilot CLI `events.jsonl` stream.
2//!
3//! ⚠️ **The `events.jsonl` schema is undocumented and reverse-engineered.**
4//! See `docs/agents/formats/copilot-cli/events.md`. Everything here is built
5//! to be *tolerant*: the line envelope accepts the payload inline, under
6//! `data`, or under `payload`; field extraction tries several key spellings;
7//! unrecognized event types fall through to [`CopilotEvent::Unknown`] so a
8//! schema change degrades gracefully instead of dropping data.
9//!
10//! When a real session is captured (see
11//! `docs/agents/formats/copilot-cli/known-gaps-and-sourcing.md`), tighten the
12//! extraction here against the observed shapes and upgrade the confidence
13//! tags in the docs.
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19use std::path::PathBuf;
20use toolpath_convo::TokenUsage;
21
22// ── Event type discriminants (v1.0.54, reverse-engineered) ───────────
23
24pub const EV_SESSION_START: &str = "session.start";
25pub const EV_SESSION_TASK_COMPLETE: &str = "session.task_complete";
26pub const EV_SESSION_SHUTDOWN: &str = "session.shutdown";
27pub const EV_SESSION_MODEL_CHANGE: &str = "session.model_change";
28pub const EV_SESSION_MODE_CHANGED: &str = "session.mode_changed";
29pub const EV_SESSION_PLAN_CHANGED: &str = "session.plan_changed";
30pub const EV_SESSION_COMPACTION_START: &str = "session.compaction_start";
31pub const EV_SESSION_COMPACTION_COMPLETE: &str = "session.compaction_complete";
32pub const EV_USER_MESSAGE: &str = "user.message";
33pub const EV_ASSISTANT_TURN_START: &str = "assistant.turn_start";
34pub const EV_ASSISTANT_MESSAGE: &str = "assistant.message";
35pub const EV_ASSISTANT_TURN_END: &str = "assistant.turn_end";
36pub const EV_TOOL_EXECUTION_START: &str = "tool.execution_start";
37pub const EV_TOOL_EXECUTION_COMPLETE: &str = "tool.execution_complete";
38pub const EV_SUBAGENT_STARTED: &str = "subagent.started";
39pub const EV_SUBAGENT_COMPLETED: &str = "subagent.completed";
40pub const EV_SKILL_INVOKED: &str = "skill.invoked";
41pub const EV_HOOK_START: &str = "hook.start";
42pub const EV_HOOK_END: &str = "hook.end";
43pub const EV_ABORT: &str = "abort";
44
45// ── Line envelope ────────────────────────────────────────────────────
46
47/// One line of `events.jsonl`.
48///
49/// The payload location is uncertain (see module docs), so we keep `data`
50/// and `payload` separately and flatten everything else into `extra`. Use
51/// [`EventLine::payload`] to get the effective payload regardless of where
52/// it landed.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EventLine {
55    #[serde(rename = "type")]
56    pub kind: String,
57
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub timestamp: Option<String>,
60
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub data: Option<Value>,
63
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub payload: Option<Value>,
66
67    /// Anything else on the line (e.g. an inline payload whose keys sit
68    /// directly on the envelope, or fields we don't model yet).
69    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
70    pub extra: HashMap<String, Value>,
71}
72
73impl EventLine {
74    /// The effective payload, regardless of where it was carried.
75    ///
76    /// Priority: `data`, then `payload`, then an object synthesized from the
77    /// inline `extra` keys.
78    pub fn payload(&self) -> Value {
79        if let Some(d) = &self.data {
80            return d.clone();
81        }
82        if let Some(p) = &self.payload {
83            return p.clone();
84        }
85        if self.extra.is_empty() {
86            Value::Null
87        } else {
88            Value::Object(
89                self.extra
90                    .iter()
91                    .map(|(k, v)| (k.clone(), v.clone()))
92                    .collect(),
93            )
94        }
95    }
96
97    pub fn parsed_timestamp(&self) -> Option<DateTime<Utc>> {
98        let ts = self.timestamp.as_ref()?;
99        DateTime::parse_from_rfc3339(ts)
100            .ok()
101            .map(|dt| dt.with_timezone(&Utc))
102    }
103
104    /// Classify this line into a [`CopilotEvent`].
105    pub fn event(&self) -> CopilotEvent {
106        let p = self.payload();
107        match self.kind.as_str() {
108            EV_SESSION_START => CopilotEvent::SessionStart(SessionStart::from_payload(&p)),
109            EV_SESSION_SHUTDOWN => CopilotEvent::SessionShutdown(SessionShutdown::from_payload(&p)),
110            EV_SESSION_COMPACTION_COMPLETE => CopilotEvent::CompactionComplete(p),
111            k if k.starts_with("session.") => CopilotEvent::SessionOther {
112                kind: k.to_string(),
113                payload: p,
114            },
115            EV_USER_MESSAGE => CopilotEvent::UserMessage(MessageEvent::from_payload(&p)),
116            EV_ASSISTANT_TURN_START => CopilotEvent::AssistantTurnStart,
117            EV_ASSISTANT_MESSAGE => CopilotEvent::AssistantMessage(MessageEvent::from_payload(&p)),
118            EV_ASSISTANT_TURN_END => CopilotEvent::AssistantTurnEnd,
119            EV_TOOL_EXECUTION_START => CopilotEvent::ToolStart(ToolExecution::from_payload(&p)),
120            EV_TOOL_EXECUTION_COMPLETE => {
121                CopilotEvent::ToolComplete(ToolExecution::from_payload(&p))
122            }
123            EV_SUBAGENT_STARTED => CopilotEvent::SubagentStarted(Subagent::from_payload(&p)),
124            EV_SUBAGENT_COMPLETED => CopilotEvent::SubagentCompleted(Subagent::from_payload(&p)),
125            EV_SKILL_INVOKED => CopilotEvent::SkillInvoked(p),
126            EV_HOOK_START | EV_HOOK_END => CopilotEvent::Hook {
127                kind: self.kind.clone(),
128                payload: p,
129            },
130            EV_ABORT => CopilotEvent::Abort(p),
131            other => CopilotEvent::Unknown {
132                kind: other.to_string(),
133                payload: p,
134            },
135        }
136    }
137}
138
139// ── Classified events ────────────────────────────────────────────────
140
141/// A semantically classified `events.jsonl` line.
142#[derive(Debug, Clone)]
143pub enum CopilotEvent {
144    SessionStart(SessionStart),
145    SessionShutdown(SessionShutdown),
146    CompactionComplete(Value),
147    /// Any other `session.*` event we don't specifically model.
148    SessionOther {
149        kind: String,
150        payload: Value,
151    },
152    UserMessage(MessageEvent),
153    AssistantTurnStart,
154    AssistantMessage(MessageEvent),
155    AssistantTurnEnd,
156    ToolStart(ToolExecution),
157    ToolComplete(ToolExecution),
158    SubagentStarted(Subagent),
159    SubagentCompleted(Subagent),
160    SkillInvoked(Value),
161    Hook {
162        kind: String,
163        payload: Value,
164    },
165    Abort(Value),
166    Unknown {
167        kind: String,
168        payload: Value,
169    },
170}
171
172/// `session.start` — the session-meta line.
173///
174/// Observed at `copilotVersion` 1.0.67: cwd and git context are nested under a
175/// `context` object (`{cwd, gitRoot, repository, branch, headCommit, ...}`);
176/// the CLI version is `copilotVersion` (the top-level `version` is an integer
177/// schema version, not the CLI version). Extraction falls back to top-level
178/// keys for tolerance.
179#[derive(Debug, Clone, Default)]
180pub struct SessionStart {
181    /// CLI version (`copilotVersion`).
182    pub copilot_version: Option<String>,
183    /// Self-reported producer (e.g. `"copilot-agent"`).
184    pub producer: Option<String>,
185    pub cwd: Option<String>,
186    pub git_root: Option<String>,
187    pub repository: Option<String>,
188    pub branch: Option<String>,
189    /// HEAD commit at session start (`context.headCommit`).
190    pub revision: Option<String>,
191    pub model: Option<String>,
192}
193
194impl SessionStart {
195    fn from_payload(p: &Value) -> Self {
196        let ctx = p.get("context").filter(|v| v.is_object());
197        // Prefer the nested `context`, fall back to top-level.
198        let cget = |keys: &[&str]| {
199            ctx.and_then(|c| str_field(c, keys))
200                .or_else(|| str_field(p, keys))
201        };
202        Self {
203            copilot_version: str_field(p, &["copilotVersion", "cliVersion", "cli_version"]),
204            producer: str_field(p, &["producer"]),
205            cwd: cget(&["cwd", "workingDirectory", "working_dir"]),
206            git_root: cget(&["gitRoot", "git_root"]),
207            repository: cget(&["repository", "repo", "remote"]),
208            branch: cget(&["branch", "gitBranch", "git_branch"]),
209            revision: cget(&["headCommit", "head_commit", "commit", "revision", "sha"]),
210            model: str_field(p, &["model", "modelId", "model_id"]),
211        }
212    }
213}
214
215/// `session.shutdown` — carries per-session token/model metrics.
216#[derive(Debug, Clone, Default)]
217pub struct SessionShutdown {
218    pub model: Option<String>,
219    pub input_tokens: Option<u32>,
220    pub output_tokens: Option<u32>,
221    pub cache_read_tokens: Option<u32>,
222    pub cache_write_tokens: Option<u32>,
223    pub total_tokens: Option<u32>,
224}
225
226impl SessionShutdown {
227    fn from_payload(p: &Value) -> Self {
228        // Observed (1.0.68): token totals live under
229        // `tokenDetails.{input,cache_read,cache_write,output}.tokenCount`, and
230        // `modelMetrics` is a map KEYED BY model name
231        // (`{"claude-haiku-4.5": {requests, usage}}`). The older reverse-eng
232        // shape (`usage.inputTokens`, `modelMetrics.model`) is kept as a
233        // fallback.
234        let td = p.get("tokenDetails");
235        let td_count =
236            |k: &str| -> Option<u32> { td?.get(k)?.get("tokenCount")?.as_u64().map(|n| n as u32) };
237        let usage = p.get("usage").unwrap_or(p);
238        let model = p
239            .get("modelMetrics")
240            .and_then(|m| {
241                // Map-keyed-by-model (observed) or `{model: "..."}` (legacy).
242                str_field(m, &["model", "modelId", "model_id"])
243                    .or_else(|| m.as_object()?.keys().next().cloned())
244            })
245            .or_else(|| str_field(p, &["model"]));
246        Self {
247            model,
248            input_tokens: td_count("input")
249                .or_else(|| u32_field(usage, &["inputTokens", "input_tokens", "promptTokens"])),
250            output_tokens: td_count("output").or_else(|| {
251                u32_field(
252                    usage,
253                    &["outputTokens", "output_tokens", "completionTokens"],
254                )
255            }),
256            cache_read_tokens: td_count("cache_read"),
257            cache_write_tokens: td_count("cache_write"),
258            total_tokens: u32_field(usage, &["totalTokens", "total_tokens"]),
259        }
260    }
261}
262
263/// A `user.message`, `assistant.message`, or `system.message`.
264#[derive(Debug, Clone, Default)]
265pub struct MessageEvent {
266    pub text: String,
267    pub model: Option<String>,
268    pub id: Option<String>,
269    /// Chain-of-thought (`reasoningText` on assistant messages).
270    pub reasoning: Option<String>,
271    /// Output tokens attributed to this message (`outputTokens`).
272    pub output_tokens: Option<u32>,
273    /// Input/prompt tokens, when present. Real Copilot sessions record only
274    /// `outputTokens` per message; `inputTokens`/cache fields are read too so a
275    /// projected session (from a harness that does carry them) round-trips.
276    pub input_tokens: Option<u32>,
277    pub cache_read_tokens: Option<u32>,
278    pub cache_write_tokens: Option<u32>,
279}
280
281impl MessageEvent {
282    fn from_payload(p: &Value) -> Self {
283        Self {
284            text: payload_text(p).unwrap_or_default(),
285            model: str_field(p, &["model", "modelId", "model_id"]),
286            id: str_field(p, &["messageId", "message_id", "id"]),
287            reasoning: payload_text_keys(p, &["reasoningText", "reasoning", "thinking"]),
288            output_tokens: u32_field(p, &["outputTokens", "output_tokens"]),
289            input_tokens: u32_field(p, &["inputTokens", "input_tokens", "promptTokens"]),
290            cache_read_tokens: u32_field(p, &["cacheReadTokens", "cache_read_tokens"]),
291            cache_write_tokens: u32_field(p, &["cacheWriteTokens", "cache_write_tokens"]),
292        }
293    }
294
295    /// Per-message token usage, or `None` when no token field was present.
296    pub fn token_usage(&self) -> Option<TokenUsage> {
297        if self.output_tokens.is_none()
298            && self.input_tokens.is_none()
299            && self.cache_read_tokens.is_none()
300            && self.cache_write_tokens.is_none()
301        {
302            return None;
303        }
304        Some(TokenUsage {
305            input_tokens: self.input_tokens,
306            output_tokens: self.output_tokens,
307            cache_read_tokens: self.cache_read_tokens,
308            cache_write_tokens: self.cache_write_tokens,
309            breakdowns: Default::default(),
310        })
311    }
312}
313
314/// A `tool.execution_start` / `tool.execution_complete`.
315#[derive(Debug, Clone, Default)]
316pub struct ToolExecution {
317    pub id: Option<String>,
318    pub name: String,
319    pub args: Value,
320    /// Present on `..._complete`; `None` means "not reported".
321    pub success: Option<bool>,
322    /// Result/output text, when carried inline on `..._complete`.
323    pub output: Option<String>,
324    /// `result.detailedContent` — for `edit`/`create` this is the git-style
325    /// diff of the actual file state `[observed, 1.0.67+]`, i.e. real
326    /// file-change fidelity (not an arg reconstruction).
327    pub detailed: Option<String>,
328}
329
330impl ToolExecution {
331    fn from_payload(p: &Value) -> Self {
332        let args = p
333            .get("args")
334            .or_else(|| p.get("arguments"))
335            .or_else(|| p.get("input"))
336            .or_else(|| p.get("parameters"))
337            .cloned()
338            .unwrap_or(Value::Null);
339        Self {
340            // Treat an empty-string id as absent so the fallback id / positional
341            // pairing kicks in (an empty toolCallId is invalid downstream).
342            id: str_field(
343                p,
344                &["id", "callId", "call_id", "toolCallId", "tool_call_id"],
345            )
346            .filter(|s| !s.trim().is_empty()),
347            name: str_field(p, &["name", "tool", "toolName", "tool_name"]).unwrap_or_default(),
348            args,
349            success: p.get("success").and_then(|v| v.as_bool()).or_else(|| {
350                // `status: "success"` / `"error"` is a plausible alternative.
351                str_field(p, &["status"]).map(|s| {
352                    let s = s.to_ascii_lowercase();
353                    s == "success" || s == "ok" || s == "completed"
354                })
355            }),
356            output: tool_output(p),
357            detailed: p
358                .get("result")
359                .and_then(|r| r.get("detailedContent"))
360                .and_then(|v| v.as_str())
361                .filter(|s| !s.trim().is_empty())
362                .map(str::to_string),
363        }
364    }
365}
366
367/// Extract a `tool.execution_complete` result to text.
368///
369/// Observed shape: `result` is an object `{content, detailedContent}`. Tolerate
370/// a plain-string `result` and other top-level output keys too.
371fn tool_output(p: &Value) -> Option<String> {
372    match p.get("result") {
373        Some(Value::String(s)) if !s.is_empty() => return Some(s.clone()),
374        Some(r @ Value::Object(_)) => {
375            if let Some(s) = payload_text_keys(
376                r,
377                &["content", "detailedContent", "text", "output", "stdout"],
378            ) {
379                return Some(s);
380            }
381        }
382        _ => {}
383    }
384    payload_text_keys(
385        p,
386        &[
387            "output",
388            "stdout",
389            "content",
390            "aggregatedOutput",
391            "aggregated_output",
392        ],
393    )
394}
395
396/// A `subagent.started` / `subagent.completed`.
397#[derive(Debug, Clone, Default)]
398pub struct Subagent {
399    pub id: Option<String>,
400    pub prompt: Option<String>,
401    pub result: Option<String>,
402}
403
404impl Subagent {
405    fn from_payload(p: &Value) -> Self {
406        Self {
407            // Observed (1.0.68): `subagent.*` are thin markers carrying
408            // `toolCallId` (correlating to the `task` tool call that holds the
409            // prompt/result), `agentName`/`agentDisplayName`/`agentDescription`,
410            // and `model` — no `id`/`prompt`/`result` of their own. Prefer the
411            // toolCallId so the delegation pairs with its tool call.
412            id: str_field(
413                p,
414                &[
415                    "toolCallId",
416                    "tool_call_id",
417                    "id",
418                    "agentId",
419                    "agent_id",
420                    "subagentId",
421                    "subagent_id",
422                ],
423            ),
424            prompt: payload_text_keys(p, &["prompt", "instruction", "input"]),
425            result: payload_text_keys(p, &["result", "output", "summary"]),
426        }
427    }
428}
429
430// ── Parsed session ───────────────────────────────────────────────────
431
432/// A parsed Copilot session: the ordered event lines plus the directory id.
433#[derive(Debug, Clone)]
434pub struct Session {
435    /// Session id (the `session-state/<id>/` directory name).
436    pub id: String,
437    /// Path to the session-state directory.
438    pub dir_path: PathBuf,
439    /// Ordered, parsed lines of `events.jsonl`.
440    pub lines: Vec<EventLine>,
441    /// Git/workspace context from the sibling `workspace.yaml`, if present.
442    pub workspace: Option<Workspace>,
443}
444
445impl Session {
446    pub fn events(&self) -> impl Iterator<Item = CopilotEvent> + '_ {
447        self.lines.iter().map(|l| l.event())
448    }
449
450    pub fn started_at(&self) -> Option<DateTime<Utc>> {
451        self.lines.iter().find_map(|l| l.parsed_timestamp())
452    }
453
454    pub fn last_activity(&self) -> Option<DateTime<Utc>> {
455        self.lines.iter().rev().find_map(|l| l.parsed_timestamp())
456    }
457
458    /// The `session.start` payload, if present.
459    pub fn start(&self) -> Option<SessionStart> {
460        self.lines.iter().find_map(|l| match l.event() {
461            CopilotEvent::SessionStart(s) => Some(s),
462            _ => None,
463        })
464    }
465
466    /// Model recorded on `session.start` (best-effort default model).
467    pub fn start_model(&self) -> Option<String> {
468        self.start().and_then(|s| s.model)
469    }
470
471    /// Working directory from `session.start` (`context.cwd`).
472    pub fn cwd(&self) -> Option<String> {
473        self.start().and_then(|s| s.cwd)
474    }
475
476    /// CLI version from `session.start` (`copilotVersion`).
477    pub fn version(&self) -> Option<String> {
478        self.start().and_then(|s| s.copilot_version)
479    }
480
481    /// First non-empty user-message text (for listing UIs).
482    pub fn first_user_text(&self) -> Option<String> {
483        self.lines.iter().find_map(|l| match l.event() {
484            CopilotEvent::UserMessage(m) if !m.text.trim().is_empty() => Some(m.text),
485            _ => None,
486        })
487    }
488}
489
490/// Git/workspace context parsed from a session's `workspace.yaml`.
491///
492/// ⚠️ `[reverse-eng, Medium]` — the exact field names and structure are
493/// unverified (see `docs/agents/formats/copilot-cli/session-state.md`). Rather
494/// than pull in a YAML dependency for a guessed schema, [`parse_workspace`]
495/// does a tolerant line scan that matches known key spellings at any
496/// indentation (so both a flat file and a one-level-nested `git:` block
497/// resolve). Correct once verified against a real `workspace.yaml`.
498#[derive(Debug, Clone, Default)]
499pub struct Workspace {
500    /// Absolute path to the repository root.
501    pub git_root: Option<String>,
502    /// Repository identifier (owner/name, or a remote URL).
503    pub repository: Option<String>,
504    /// Active branch at session time.
505    pub branch: Option<String>,
506    /// Commit hash, when recorded.
507    pub revision: Option<String>,
508}
509
510impl Workspace {
511    /// True when nothing useful was extracted.
512    pub fn is_empty(&self) -> bool {
513        self.git_root.is_none()
514            && self.repository.is_none()
515            && self.branch.is_none()
516            && self.revision.is_none()
517    }
518}
519
520/// Parse a (reverse-engineered) `workspace.yaml` body.
521///
522/// Tolerant by design: scans each line for a `key: value` pair, matching known
523/// key spellings after trimming indentation (so nested blocks still resolve),
524/// unquoting the value, and taking the first non-empty match per field.
525pub fn parse_workspace(content: &str) -> Workspace {
526    const ROOT_KEYS: &[&str] = &["git_root", "gitroot", "root", "worktree", "workspace_root"];
527    const REPO_KEYS: &[&str] = &[
528        "repository",
529        "repo",
530        "remote",
531        "origin",
532        "repository_url",
533        "remote_url",
534    ];
535    const BRANCH_KEYS: &[&str] = &["branch", "git_branch", "current_branch"];
536    const REV_KEYS: &[&str] = &["revision", "commit", "commit_hash", "sha", "head"];
537
538    let mut ws = Workspace::default();
539    for raw in content.lines() {
540        let line = raw.trim();
541        if line.is_empty() || line.starts_with('#') {
542            continue;
543        }
544        let Some((key, value)) = line.split_once(':') else {
545            continue;
546        };
547        let key = key
548            .trim()
549            .trim_matches(|c| c == '"' || c == '\'')
550            .to_ascii_lowercase();
551        let value = unquote(value.trim());
552        if value.is_empty() {
553            continue; // block header like `git:` — no scalar to take
554        }
555        let slot = if ROOT_KEYS.contains(&key.as_str()) {
556            &mut ws.git_root
557        } else if REPO_KEYS.contains(&key.as_str()) {
558            &mut ws.repository
559        } else if BRANCH_KEYS.contains(&key.as_str()) {
560            &mut ws.branch
561        } else if REV_KEYS.contains(&key.as_str()) {
562            &mut ws.revision
563        } else {
564            continue;
565        };
566        if slot.is_none() {
567            *slot = Some(value.to_string());
568        }
569    }
570    ws
571}
572
573fn unquote(s: &str) -> &str {
574    let s = s.trim();
575    if s.len() >= 2
576        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
577    {
578        &s[1..s.len() - 1]
579    } else {
580        s
581    }
582}
583
584/// Lightweight metadata for a session, for listing without a full walk.
585#[derive(Debug, Clone)]
586pub struct SessionMetadata {
587    pub id: String,
588    pub dir_path: PathBuf,
589    pub started_at: Option<DateTime<Utc>>,
590    pub last_activity: Option<DateTime<Utc>>,
591    pub cwd: Option<String>,
592    pub version: Option<String>,
593    pub first_user_message: Option<String>,
594    pub line_count: usize,
595}
596
597// ── Field-extraction helpers (tolerant of key-name variance) ─────────
598
599/// First present string-valued key among `keys`.
600fn str_field(v: &Value, keys: &[&str]) -> Option<String> {
601    for k in keys {
602        if let Some(s) = v.get(*k).and_then(|x| x.as_str()) {
603            return Some(s.to_string());
604        }
605    }
606    None
607}
608
609/// First present unsigned-int-valued key among `keys`.
610fn u32_field(v: &Value, keys: &[&str]) -> Option<u32> {
611    for k in keys {
612        if let Some(n) = v.get(*k).and_then(|x| x.as_u64()) {
613            return Some(n as u32);
614        }
615    }
616    None
617}
618
619/// Extract human text from a message-shaped payload.
620///
621/// Handles `text`, a string `content`, an array `content[].text`, and
622/// `message`.
623fn payload_text(v: &Value) -> Option<String> {
624    payload_text_keys(v, &["text", "content", "message"])
625}
626
627/// Like [`payload_text`] but over an explicit key list; tolerates string,
628/// array-of-parts, and object-with-text shapes.
629fn payload_text_keys(v: &Value, keys: &[&str]) -> Option<String> {
630    for k in keys {
631        match v.get(*k) {
632            Some(Value::String(s)) => {
633                if !s.is_empty() {
634                    return Some(s.clone());
635                }
636            }
637            Some(Value::Array(parts)) => {
638                let joined: String = parts
639                    .iter()
640                    .filter_map(|part| match part {
641                        Value::String(s) => Some(s.clone()),
642                        Value::Object(_) => part
643                            .get("text")
644                            .and_then(|t| t.as_str())
645                            .map(|s| s.to_string()),
646                        _ => None,
647                    })
648                    .collect::<Vec<_>>()
649                    .join("");
650                if !joined.is_empty() {
651                    return Some(joined);
652                }
653            }
654            Some(Value::Object(_)) => {
655                if let Some(s) = v[*k].get("text").and_then(|t| t.as_str()) {
656                    return Some(s.to_string());
657                }
658            }
659            _ => {}
660        }
661    }
662    None
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use serde_json::json;
669
670    fn line(kind: &str, data: Value) -> EventLine {
671        EventLine {
672            kind: kind.to_string(),
673            timestamp: Some("2026-06-30T10:00:00.000Z".to_string()),
674            data: Some(data),
675            payload: None,
676            extra: HashMap::new(),
677        }
678    }
679
680    #[test]
681    fn payload_prefers_data_then_payload_then_inline() {
682        let l = EventLine {
683            kind: "x".into(),
684            timestamp: None,
685            data: Some(json!({"a": 1})),
686            payload: Some(json!({"b": 2})),
687            extra: HashMap::new(),
688        };
689        assert_eq!(l.payload(), json!({"a": 1}));
690
691        let l2 = EventLine {
692            kind: "x".into(),
693            timestamp: None,
694            data: None,
695            payload: Some(json!({"b": 2})),
696            extra: HashMap::new(),
697        };
698        assert_eq!(l2.payload(), json!({"b": 2}));
699
700        let mut extra = HashMap::new();
701        extra.insert("c".to_string(), json!(3));
702        let l3 = EventLine {
703            kind: "x".into(),
704            timestamp: None,
705            data: None,
706            payload: None,
707            extra,
708        };
709        assert_eq!(l3.payload(), json!({"c": 3}));
710    }
711
712    #[test]
713    fn classifies_known_events() {
714        assert!(matches!(
715            line(EV_USER_MESSAGE, json!({"text": "hi"})).event(),
716            CopilotEvent::UserMessage(_)
717        ));
718        assert!(matches!(
719            line(EV_TOOL_EXECUTION_START, json!({"name": "shell"})).event(),
720            CopilotEvent::ToolStart(_)
721        ));
722        assert!(matches!(
723            line(EV_ASSISTANT_TURN_END, json!({})).event(),
724            CopilotEvent::AssistantTurnEnd
725        ));
726    }
727
728    #[test]
729    fn unknown_event_falls_through() {
730        match line("brand.new_event", json!({"k": 1})).event() {
731            CopilotEvent::Unknown { kind, .. } => assert_eq!(kind, "brand.new_event"),
732            other => panic!("expected Unknown, got {other:?}"),
733        }
734    }
735
736    #[test]
737    fn session_other_for_unmodeled_session_events() {
738        match line(EV_SESSION_MODEL_CHANGE, json!({"model": "x"})).event() {
739            CopilotEvent::SessionOther { kind, .. } => assert_eq!(kind, EV_SESSION_MODEL_CHANGE),
740            other => panic!("expected SessionOther, got {other:?}"),
741        }
742    }
743
744    #[test]
745    fn message_text_handles_string_array_and_object() {
746        assert_eq!(
747            MessageEvent::from_payload(&json!({"text": "plain"})).text,
748            "plain"
749        );
750        assert_eq!(
751            MessageEvent::from_payload(&json!({"content": [{"text": "a"}, {"text": "b"}]})).text,
752            "ab"
753        );
754        assert_eq!(
755            MessageEvent::from_payload(&json!({"content": "str"})).text,
756            "str"
757        );
758    }
759
760    #[test]
761    fn tool_execution_extracts_args_and_success() {
762        let t = ToolExecution::from_payload(&json!({
763            "call_id": "c1", "name": "shell",
764            "args": {"command": "ls"}, "success": true, "output": "a.rs"
765        }));
766        assert_eq!(t.id.as_deref(), Some("c1"));
767        assert_eq!(t.name, "shell");
768        assert_eq!(t.args, json!({"command": "ls"}));
769        assert_eq!(t.success, Some(true));
770        assert_eq!(t.output.as_deref(), Some("a.rs"));
771    }
772
773    #[test]
774    fn tool_status_string_maps_to_success() {
775        let ok = ToolExecution::from_payload(&json!({"name": "x", "status": "success"}));
776        assert_eq!(ok.success, Some(true));
777        let err = ToolExecution::from_payload(&json!({"name": "x", "status": "error"}));
778        assert_eq!(err.success, Some(false));
779    }
780
781    #[test]
782    fn shutdown_reads_nested_usage_camelcase() {
783        let s = SessionShutdown::from_payload(&json!({
784            "modelMetrics": {"model": "gpt-5-copilot"},
785            "usage": {"inputTokens": 1200, "outputTokens": 340}
786        }));
787        assert_eq!(s.model.as_deref(), Some("gpt-5-copilot"));
788        assert_eq!(s.input_tokens, Some(1200));
789        assert_eq!(s.output_tokens, Some(340));
790    }
791
792    #[test]
793    fn session_start_reads_nested_context() {
794        // Real shape (copilotVersion 1.0.67): cwd/git under `context`.
795        let s = SessionStart::from_payload(&json!({
796            "copilotVersion": "1.0.67",
797            "version": 1,
798            "producer": "copilot-agent",
799            "context": {
800                "cwd": "/x/proj", "gitRoot": "/x/proj", "repository": "o/r",
801                "branch": "main", "headCommit": "deadbeef"
802            }
803        }));
804        assert_eq!(s.copilot_version.as_deref(), Some("1.0.67"));
805        assert_eq!(s.producer.as_deref(), Some("copilot-agent"));
806        assert_eq!(s.cwd.as_deref(), Some("/x/proj"));
807        assert_eq!(s.repository.as_deref(), Some("o/r"));
808        assert_eq!(s.branch.as_deref(), Some("main"));
809        assert_eq!(s.revision.as_deref(), Some("deadbeef"));
810    }
811
812    #[test]
813    fn session_start_falls_back_to_top_level_cwd() {
814        let s = SessionStart::from_payload(&json!({"cwd": "/legacy", "copilotVersion": "1.0.0"}));
815        assert_eq!(s.cwd.as_deref(), Some("/legacy"));
816    }
817
818    #[test]
819    fn tool_output_reads_result_object() {
820        // Real shape: result is {content, detailedContent}.
821        let t = ToolExecution::from_payload(&json!({
822            "toolCallId": "c1", "toolName": "bash", "success": true,
823            "result": {"content": "ok", "detailedContent": "ok\nmore"}
824        }));
825        assert_eq!(t.id.as_deref(), Some("c1"));
826        assert_eq!(t.name, "bash");
827        assert_eq!(t.output.as_deref(), Some("ok"));
828        // Legacy top-level output still works.
829        let t2 = ToolExecution::from_payload(&json!({"name": "x", "output": "plain"}));
830        assert_eq!(t2.output.as_deref(), Some("plain"));
831    }
832
833    #[test]
834    fn message_reads_reasoning_and_output_tokens() {
835        let m = MessageEvent::from_payload(&json!({
836            "content": "hi", "reasoningText": "thinking", "outputTokens": 42, "model": "claude-haiku-4.5"
837        }));
838        assert_eq!(m.text, "hi");
839        assert_eq!(m.reasoning.as_deref(), Some("thinking"));
840        assert_eq!(m.output_tokens, Some(42));
841        assert_eq!(m.model.as_deref(), Some("claude-haiku-4.5"));
842    }
843
844    #[test]
845    fn parse_workspace_flat() {
846        let ws = parse_workspace(
847            "git_root: /home/x/proj\nrepository: git@github.com:o/r.git\nbranch: main\n",
848        );
849        assert_eq!(ws.git_root.as_deref(), Some("/home/x/proj"));
850        assert_eq!(ws.repository.as_deref(), Some("git@github.com:o/r.git"));
851        assert_eq!(ws.branch.as_deref(), Some("main"));
852        assert!(ws.revision.is_none());
853        assert!(!ws.is_empty());
854    }
855
856    #[test]
857    fn parse_workspace_tolerates_nesting_quotes_and_comments() {
858        // A nested `git:` block header (no scalar) plus quoted, indented values.
859        let ws = parse_workspace(
860            "# session workspace\ngit:\n  root: \"/tmp/p\"\n  branch: 'feature/x'\n  commit: abc123\n",
861        );
862        assert_eq!(ws.git_root.as_deref(), Some("/tmp/p"));
863        assert_eq!(ws.branch.as_deref(), Some("feature/x"));
864        assert_eq!(ws.revision.as_deref(), Some("abc123"));
865    }
866
867    #[test]
868    fn parse_workspace_empty_when_no_known_keys() {
869        assert!(parse_workspace("unrelated: value\nfoo: bar\n").is_empty());
870        assert!(parse_workspace("").is_empty());
871    }
872}