diff --git a/src/events.rs b/src/events.rs index 6748266..5f485da 100644 --- a/src/events.rs +++ b/src/events.rs @@ -119,6 +119,39 @@ pub struct SessionStartData { pub struct SessionResumeData { pub resume_time: String, pub event_count: f64, + /// Optional CLI-supplied workspace context. Copilot CLI 1.0.35+ sends + /// this object alongside the basic resume metadata so downstream + /// consumers (IDE integrations, session dashboards) can rebind to the + /// right working directory, git branch, and remote without re-reading + /// the session store. Older CLI versions omit the field entirely, + /// hence `Option` + `#[serde(default)]` keeps the type backward + /// compatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, +} + +/// Workspace context payload attached to a `session.resume` event by the +/// Copilot CLI. Every field is optional because the CLI may omit any +/// subset depending on whether the resumed session lives inside a git +/// repository and which host (IDE / terminal / etc.) initiated the +/// resume. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionResumeContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_type: Option, } /// Data for session.error event. @@ -1447,4 +1480,76 @@ mod tests { let event = SessionEvent::from_json(&json).unwrap(); assert_eq!(event.event_type, "session.usage_info"); } + + /// Regression fixture derived from the Copilot CLI 1.0.35 wire trace + /// captured during DevInSip POC-3 (see docs/POC_COPILOT_SDK.md in the + /// DevInSip repo). The CLI emits `session.resume` with an additional + /// `context` object that the pre-patch SDK silently dropped, leaving + /// downstream consumers (session binding, sidebar cwd) unable to + /// recover the working directory without bypassing the SDK. + #[test] + fn test_session_resume_preserves_cli_context() { + let json = json!({ + "id": "evt_resume_1", + "timestamp": "2026-04-24T00:00:00Z", + "type": "session.resume", + "data": { + "resumeTime": "2026-04-24T00:00:00Z", + "eventCount": 42.0, + "context": { + "cwd": "/Users/dev/project", + "gitRoot": "/Users/dev/project", + "branch": "main", + "baseCommit": "abc123", + "headCommit": "def456", + "repository": "github.com/org/repo", + "hostType": "vscode" + } + } + }); + + let event = SessionEvent::from_json(&json).unwrap(); + assert_eq!(event.event_type, "session.resume"); + + let resume = match &event.data { + SessionEventData::SessionResume(r) => r, + other => panic!("expected SessionResume variant, got {other:?}"), + }; + assert_eq!(resume.resume_time, "2026-04-24T00:00:00Z"); + assert_eq!(resume.event_count, 42.0); + + let ctx = resume + .context + .as_ref() + .expect("context must round-trip through SessionResumeData"); + assert_eq!(ctx.cwd.as_deref(), Some("/Users/dev/project")); + assert_eq!(ctx.git_root.as_deref(), Some("/Users/dev/project")); + assert_eq!(ctx.branch.as_deref(), Some("main")); + assert_eq!(ctx.base_commit.as_deref(), Some("abc123")); + assert_eq!(ctx.head_commit.as_deref(), Some("def456")); + assert_eq!(ctx.repository.as_deref(), Some("github.com/org/repo")); + assert_eq!(ctx.host_type.as_deref(), Some("vscode")); + } + + /// CLI payloads that omit `context` must still deserialize successfully + /// — the field is additive and backward-compatible. + #[test] + fn test_session_resume_without_context_is_backward_compatible() { + let json = json!({ + "id": "evt_resume_2", + "timestamp": "2026-04-24T00:00:00Z", + "type": "session.resume", + "data": { + "resumeTime": "2026-04-24T00:00:00Z", + "eventCount": 0.0 + } + }); + + let event = SessionEvent::from_json(&json).unwrap(); + let resume = match &event.data { + SessionEventData::SessionResume(r) => r, + other => panic!("expected SessionResume variant, got {other:?}"), + }; + assert!(resume.context.is_none()); + } }