feat(events): preserve CLI context on session.resume#8
Conversation
Copilot CLI 1.0.35+ emits session.resume events with a `context` object containing cwd, gitRoot, branch, baseCommit, headCommit, repository, and hostType — all dropped silently by the previous SessionResumeData definition. Adds an optional `context: Option<SessionResumeContext>` field with camelCase serde rename, making the extension additive and backward compatible: pre-1.0.35 payloads without the field still deserialize unchanged. The new SessionResumeContext struct uses Option<String> for every nested field to absorb host-specific omissions. Regression fixture derived from wire traces captured during a downstream consumer POC (DevInSip docs/POC_COPILOT_SDK.md); two tests cover the context-present and context-absent paths. Full lib test suite passes (130/130).
There was a problem hiding this comment.
Pull request overview
Adds support for preserving Copilot CLI 1.0.35+ session.resume workspace context in the SDK’s typed event model so downstream consumers can recover cwd/git/host metadata directly from the event stream.
Changes:
- Extend
SessionResumeDatawith an optionalcontextpayload. - Introduce
SessionResumeContextmatching the CLI’scontextwire shape (camelCase). - Add regression tests covering payloads with and without
context.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// 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. |
There was a problem hiding this comment.
The regression-test doc comment references an external repo/POC document (“DevInSip POC-3”, “docs/POC_COPILOT_SDK.md”). That makes the test less self-contained and can become confusing/stale for future maintainers. Consider rewriting this comment to describe the provenance generically (e.g., “captured from Copilot CLI 1.0.35 wire trace”) without external repo names/paths.
| /// 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. | |
| /// Regression fixture derived from a Copilot CLI 1.0.35 wire trace. | |
| /// 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. |
| /// 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")] |
There was a problem hiding this comment.
context is an Option, so missing JSON already deserializes to None without #[serde(default)]. The doc comment also implies #[serde(default)] is needed for backward compatibility, which isn’t true for Option fields. Consider dropping default here (keeping skip_serializing_if) and adjusting the comment accordingly.
| /// 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")] | |
| /// the session store. Older CLI versions may omit the field entirely, | |
| /// and `Option` keeps deserialization backward compatible in that case. | |
| #[serde(skip_serializing_if = "Option::is_none")] |
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub cwd: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub git_root: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub branch: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub base_commit: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub head_commit: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub repository: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] |
There was a problem hiding this comment.
In SessionResumeContext, each field is Option<...> and will already default to None when omitted; the per-field #[serde(default)] is redundant and inconsistent with other Option fields in this file (which typically only use skip_serializing_if). Consider removing default on these fields to reduce annotation noise.
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub cwd: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub git_root: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub branch: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub base_commit: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub head_commit: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub repository: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub cwd: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub git_root: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub branch: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub base_commit: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub head_commit: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub repository: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] |
| let ctx = resume | ||
| .context | ||
| .as_ref() | ||
| .expect("context must round-trip through SessionResumeData"); | ||
| assert_eq!(ctx.cwd.as_deref(), Some("/Users/dev/project")); |
There was a problem hiding this comment.
This test’s comments/error message say the context must “round-trip”, but the test only verifies deserialization (from_json). Either extend the test to serialize back to JSON and re-parse, or adjust the wording to avoid implying serialization coverage.
Summary
Copilot CLI 1.0.35+ emits
session.resumeevents with acontextobject containingcwd,gitRoot,branch,baseCommit,headCommit,repository, andhostType. The currentSessionResumeDatasilently drops this payload, leaving downstream consumers (IDE integrations, session dashboards) unable to rebind the working directory or remote without reading the session store out-of-band.This PR adds an optional
context: Option<SessionResumeContext>field toSessionResumeData, making the extension additive and backward compatible — pre-1.0.35 payloads that omit the field continue to deserialize unchanged.Changes
SessionResumeContextstruct mirroring the CLI wire shape (every nested field isOption<String>since hosts can omit any subset).SessionResumeData.context: Option<SessionResumeContext>with#[serde(default, skip_serializing_if = "Option::is_none")].events::tests:test_session_resume_preserves_cli_context— full payload round-trips, every CLI field recoverable.test_session_resume_without_context_is_backward_compatible— omitted field isNone, no error.Verification
cargo test --lib -p copilot-sdk— 130 passed / 0 failed / 0 regressions.--server --stdio).Context
Discovered while building a Rust SDK consumer that relies on
session.resumeto rebind UI state to the resumed session's working directory. Without this patch the only workaround is to bypass the SDK's typed event stream and re-parse the raw JSON-RPCparams.data, which defeats the purpose of a typed SDK.Follow-up (separate PR candidate):
parse_event_datacurrently replaces failed deserialization withUnknown(Value::Null), discarding the original JSON and hiding schema drift. Worth revisiting once this context extension lands.