Skip to content

Commit a60a54b

Browse files
tclemCopilot
andcommitted
Add agentId envelope field on session events for sub-agent attribution
The wire-protocol schema added top-level `agentId?: string` to every session event envelope in commit f8cf846 ("Derive session event envelopes from schema") for sub-agent attribution. Every other SDK carries it; Rust silently drops it at the deserialization boundary. Concretely: the schema describes `agentId` as "Sub-agent instance identifier. Absent for events from the root/main agent and session- level events." Without the field, Rust consumers can't distinguish events emitted by a sub-agent from events emitted by the root agent. Adds: - `pub agent_id: Option<String>` on `types::SessionEvent` (hand- authored consumer-facing). - `pub agent_id: Option<String>` on `generated::session_events::TypedSessionEvent` via codegen update in `scripts/codegen/rust.ts`. - `#[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]` via the existing container `rename_all = "camelCase"` (covered) plus skip-on-None for clean wire output. Round-trip tests cover both struct shapes — sub-agent event with the field set, root-agent event without — to lock the parity in place against future schema changes or codegen regressions. Caught by a fresh parity audit against Node/Python/Go/.NET as part of the post-main-merge gap review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ac706d commit a60a54b

7 files changed

Lines changed: 124 additions & 8 deletions

File tree

rust/.parity_node.txt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
AssistantMessageToolRequestType
2+
ElicitationCompletedAction
3+
ElicitationCompletedContent
4+
ElicitationRequestedMode
5+
ExtensionsLoadedExtensionSource
6+
ExtensionsLoadedExtensionStatus
7+
HandoffSourceType
8+
McpServerStatusChangedStatus
9+
McpServersLoadedServerStatus
10+
ModelCallFailureSource
11+
PermissionPromptRequest
12+
PermissionPromptRequestMemoryAction
13+
PermissionPromptRequestMemoryDirection
14+
PermissionPromptRequestPathAccessKind
15+
PermissionRequest
16+
PermissionRequestMemoryAction
17+
PermissionRequestMemoryDirection
18+
PermissionResult
19+
PlanChangedOperation
20+
SessionEvent
21+
ShutdownType
22+
SystemMessageRole
23+
SystemNotification
24+
SystemNotificationAgentCompletedStatus
25+
ToolExecutionCompleteContent
26+
ToolExecutionCompleteContentResourceDetails
27+
ToolExecutionCompleteContentResourceLinkIconTheme
28+
UserMessageAgentMode
29+
UserMessageAttachment
30+
UserMessageAttachmentGithubReferenceType
31+
UserToolSessionApproval
32+
WorkingDirectoryContextHostType
33+
WorkspaceFileChangedOperation

rust/.parity_rust.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub

rust/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,14 @@ public surface.
423423
fail fast as before.
424424

425425
### Fixed
426+
- `SessionEvent` and `TypedSessionEvent` now expose the `agentId`
427+
envelope field added to `session-events.schema.json` upstream
428+
(`f8cf846`, "Derive session event envelopes from schema"). Sub-agent
429+
events were silently dropping the attribution at the deserialization
430+
boundary; consumers had no way to distinguish events emitted by the
431+
root agent from events emitted by a sub-agent. Other SDKs (Node,
432+
Python, Go, .NET) all carry this field. Round-trip parity test added
433+
in `types::tests::session_event_round_trips_agent_id_on_envelope`.
426434
- `Session::user_input` no longer double-dispatches when the CLI sends
427435
both a `user_input.requested` notification (for observers) and a
428436
`userInput.request` JSON-RPC call (the actual prompt) for the same

rust/src/generated/session_events.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,9 +336,9 @@ pub enum SessionEventData {
336336

337337
/// A session event with typed data payload.
338338
///
339-
/// The common event fields (id, timestamp, parentId, ephemeral) are
340-
/// available directly. The event-specific data is in the `payload` field
341-
/// as a [`SessionEventData`] enum.
339+
/// The common event fields (id, timestamp, parentId, ephemeral, agentId)
340+
/// are available directly. The event-specific data is in the `payload`
341+
/// field as a [`SessionEventData`] enum.
342342
#[derive(Debug, Clone, Serialize, Deserialize)]
343343
#[serde(rename_all = "camelCase")]
344344
pub struct TypedSessionEvent {
@@ -352,6 +352,10 @@ pub struct TypedSessionEvent {
352352
/// When true, the event is transient and not persisted.
353353
#[serde(skip_serializing_if = "Option::is_none")]
354354
pub ephemeral: Option<bool>,
355+
/// Sub-agent instance identifier. Absent for events from the root /
356+
/// main agent and session-level events.
357+
#[serde(skip_serializing_if = "Option::is_none")]
358+
pub agent_id: Option<String>,
355359
/// The typed event payload (discriminated by event type).
356360
#[serde(flatten)]
357361
pub payload: SessionEventData,

rust/src/subscription.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ mod tests {
166166
timestamp: "2025-01-01T00:00:00Z".into(),
167167
parent_id: None,
168168
ephemeral: None,
169+
agent_id: None,
169170
debug_cli_received_at_ms: None,
170171
debug_ws_forwarded_at_ms: None,
171172
event_type: "noop".into(),

rust/src/types.rs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2542,6 +2542,10 @@ pub struct SessionEvent {
25422542
/// Transient events that are not persisted to disk.
25432543
#[serde(skip_serializing_if = "Option::is_none")]
25442544
pub ephemeral: Option<bool>,
2545+
/// Sub-agent instance identifier. Absent for events emitted by the
2546+
/// root/main agent and for session-level events.
2547+
#[serde(skip_serializing_if = "Option::is_none")]
2548+
pub agent_id: Option<String>,
25452549
/// Debug timestamp: when the CLI received this event (ms since epoch).
25462550
#[serde(skip_serializing_if = "Option::is_none")]
25472551
pub debug_cli_received_at_ms: Option<i64>,
@@ -2987,9 +2991,10 @@ mod tests {
29872991
use super::{
29882992
Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange,
29892993
ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType,
2990-
InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionId,
2991-
SystemMessageConfig, Tool, ensure_attachment_display_names,
2994+
InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent,
2995+
SessionId, SystemMessageConfig, Tool, ensure_attachment_display_names,
29922996
};
2997+
use crate::generated::session_events::TypedSessionEvent;
29932998

29942999
#[test]
29953000
fn tool_builder_composes() {
@@ -3252,6 +3257,62 @@ mod tests {
32523257
assert_eq!(parsed, ConnectionState::Error);
32533258
}
32543259

3260+
/// `agentId` is the sub-agent attribution field added in copilot-sdk
3261+
/// commit f8cf846 ("Derive session event envelopes from schema").
3262+
/// Every other SDK (Node, Python, Go, .NET) carries it on the event
3263+
/// envelope; Rust must too or sub-agent events lose attribution at
3264+
/// the deserialization boundary. Cross-SDK parity test.
3265+
#[test]
3266+
fn session_event_round_trips_agent_id_on_envelope() {
3267+
let wire = json!({
3268+
"id": "evt-1",
3269+
"timestamp": "2026-04-30T12:00:00Z",
3270+
"parentId": null,
3271+
"agentId": "sub-agent-42",
3272+
"type": "assistant.message",
3273+
"data": { "message": "hi" }
3274+
});
3275+
3276+
let event: SessionEvent = serde_json::from_value(wire.clone()).unwrap();
3277+
assert_eq!(event.agent_id.as_deref(), Some("sub-agent-42"));
3278+
3279+
// Round-trip preserves the field on the wire.
3280+
let roundtripped = serde_json::to_value(&event).unwrap();
3281+
assert_eq!(roundtripped["agentId"], "sub-agent-42");
3282+
3283+
// Absent agentId remains absent (skip_serializing_if).
3284+
let main_agent_event: SessionEvent = serde_json::from_value(json!({
3285+
"id": "evt-2",
3286+
"timestamp": "2026-04-30T12:00:01Z",
3287+
"parentId": null,
3288+
"type": "session.idle",
3289+
"data": {}
3290+
}))
3291+
.unwrap();
3292+
assert!(main_agent_event.agent_id.is_none());
3293+
let roundtripped = serde_json::to_value(&main_agent_event).unwrap();
3294+
assert!(roundtripped.get("agentId").is_none());
3295+
}
3296+
3297+
/// Same parity for the typed event envelope produced by the codegen.
3298+
#[test]
3299+
fn typed_session_event_round_trips_agent_id_on_envelope() {
3300+
let wire = json!({
3301+
"id": "evt-1",
3302+
"timestamp": "2026-04-30T12:00:00Z",
3303+
"parentId": null,
3304+
"agentId": "sub-agent-42",
3305+
"type": "session.idle",
3306+
"data": {}
3307+
});
3308+
3309+
let event: TypedSessionEvent = serde_json::from_value(wire).unwrap();
3310+
assert_eq!(event.agent_id.as_deref(), Some("sub-agent-42"));
3311+
3312+
let roundtripped = serde_json::to_value(&event).unwrap();
3313+
assert_eq!(roundtripped["agentId"], "sub-agent-42");
3314+
}
3315+
32553316
#[test]
32563317
fn connection_state_other_variants_serialize_as_lowercase() {
32573318
assert_eq!(

scripts/codegen/rust.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -747,12 +747,12 @@ function generateSessionEventsCode(schema: JSONSchema7): string {
747747
typedEventLines.push("/// A session event with typed data payload.");
748748
typedEventLines.push("///");
749749
typedEventLines.push(
750-
"/// The common event fields (id, timestamp, parentId, ephemeral) are",
750+
"/// The common event fields (id, timestamp, parentId, ephemeral, agentId)",
751751
);
752752
typedEventLines.push(
753-
"/// available directly. The event-specific data is in the `payload` field",
753+
"/// are available directly. The event-specific data is in the `payload`",
754754
);
755-
typedEventLines.push("/// as a [`SessionEventData`] enum.");
755+
typedEventLines.push("/// field as a [`SessionEventData`] enum.");
756756
typedEventLines.push("#[derive(Debug, Clone, Serialize, Deserialize)]");
757757
typedEventLines.push(`#[serde(rename_all = "camelCase")]`);
758758
typedEventLines.push("pub struct TypedSessionEvent {");
@@ -770,6 +770,14 @@ function generateSessionEventsCode(schema: JSONSchema7): string {
770770
);
771771
typedEventLines.push(` #[serde(skip_serializing_if = "Option::is_none")]`);
772772
typedEventLines.push(" pub ephemeral: Option<bool>,");
773+
typedEventLines.push(
774+
" /// Sub-agent instance identifier. Absent for events from the root /",
775+
);
776+
typedEventLines.push(
777+
" /// main agent and session-level events.",
778+
);
779+
typedEventLines.push(` #[serde(skip_serializing_if = "Option::is_none")]`);
780+
typedEventLines.push(" pub agent_id: Option<String>,");
773781
typedEventLines.push(
774782
" /// The typed event payload (discriminated by event type).",
775783
);

0 commit comments

Comments
 (0)