1use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19use std::path::PathBuf;
20use toolpath_convo::TokenUsage;
21
22pub 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#[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 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
70 pub extra: HashMap<String, Value>,
71}
72
73impl EventLine {
74 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 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#[derive(Debug, Clone)]
143pub enum CopilotEvent {
144 SessionStart(SessionStart),
145 SessionShutdown(SessionShutdown),
146 CompactionComplete(Value),
147 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#[derive(Debug, Clone, Default)]
180pub struct SessionStart {
181 pub copilot_version: Option<String>,
183 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 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 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#[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 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 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#[derive(Debug, Clone, Default)]
265pub struct MessageEvent {
266 pub text: String,
267 pub model: Option<String>,
268 pub id: Option<String>,
269 pub reasoning: Option<String>,
271 pub output_tokens: Option<u32>,
273 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 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#[derive(Debug, Clone, Default)]
316pub struct ToolExecution {
317 pub id: Option<String>,
318 pub name: String,
319 pub args: Value,
320 pub success: Option<bool>,
322 pub output: Option<String>,
324 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 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 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
367fn 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#[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 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#[derive(Debug, Clone)]
434pub struct Session {
435 pub id: String,
437 pub dir_path: PathBuf,
439 pub lines: Vec<EventLine>,
441 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 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 pub fn start_model(&self) -> Option<String> {
468 self.start().and_then(|s| s.model)
469 }
470
471 pub fn cwd(&self) -> Option<String> {
473 self.start().and_then(|s| s.cwd)
474 }
475
476 pub fn version(&self) -> Option<String> {
478 self.start().and_then(|s| s.copilot_version)
479 }
480
481 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#[derive(Debug, Clone, Default)]
499pub struct Workspace {
500 pub git_root: Option<String>,
502 pub repository: Option<String>,
504 pub branch: Option<String>,
506 pub revision: Option<String>,
508}
509
510impl Workspace {
511 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
520pub 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; }
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#[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
597fn 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
609fn 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
619fn payload_text(v: &Value) -> Option<String> {
624 payload_text_keys(v, &["text", "content", "message"])
625}
626
627fn 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 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 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 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 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}