diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 7842f77ef..d4eddd94d 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -103,22 +103,27 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(UserMessageEvent), "user.message")] public partial class SessionEvent { + /// Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("agentId")] + public string? AgentId { get; set; } + + /// When true, the event is transient and not persisted to the session event log on disk. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("ephemeral")] + public bool? Ephemeral { get; set; } + /// Unique event identifier (UUID v4), generated when the event is emitted. [JsonPropertyName("id")] public Guid Id { get; set; } - /// ISO 8601 timestamp when the event was created. - [JsonPropertyName("timestamp")] - public DateTimeOffset Timestamp { get; set; } - /// ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. [JsonPropertyName("parentId")] public Guid? ParentId { get; set; } - /// When true, the event is transient and not persisted to the session event log on disk. - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("ephemeral")] - public bool? Ephemeral { get; set; } + /// ISO 8601 timestamp when the event was created. + [JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } /// /// The event type discriminator. diff --git a/dotnet/test/ForwardCompatibilityTests.cs b/dotnet/test/ForwardCompatibilityTests.cs index d3f5b7785..71df3f0ea 100644 --- a/dotnet/test/ForwardCompatibilityTests.cs +++ b/dotnet/test/ForwardCompatibilityTests.cs @@ -20,6 +20,7 @@ public void FromJson_KnownEventType_DeserializesNormally() "id": "00000000-0000-0000-0000-000000000001", "timestamp": "2026-01-01T00:00:00Z", "parentId": null, + "agentId": "agent-1", "type": "user.message", "data": { "content": "Hello" @@ -31,6 +32,7 @@ public void FromJson_KnownEventType_DeserializesNormally() Assert.IsType(result); Assert.Equal("user.message", result.Type); + Assert.Equal("agent-1", result.AgentId); } [Fact] @@ -41,6 +43,7 @@ public void FromJson_UnknownEventType_ReturnsBaseSessionEvent() "id": "12345678-1234-1234-1234-123456789abc", "timestamp": "2026-06-15T10:30:00Z", "parentId": "abcdefab-abcd-abcd-abcd-abcdefabcdef", + "agentId": "future-agent", "type": "future.feature_from_server", "data": { "key": "value" } } @@ -50,6 +53,7 @@ public void FromJson_UnknownEventType_ReturnsBaseSessionEvent() Assert.IsType(result); Assert.Equal("unknown", result.Type); + Assert.Equal("future-agent", result.AgentId); } [Fact] diff --git a/dotnet/test/SessionEventSerializationTests.cs b/dotnet/test/SessionEventSerializationTests.cs index 93e5ae935..0651e2420 100644 --- a/dotnet/test/SessionEventSerializationTests.cs +++ b/dotnet/test/SessionEventSerializationTests.cs @@ -18,6 +18,7 @@ public class SessionEventSerializationTests Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), Timestamp = DateTimeOffset.Parse("2026-03-15T21:26:02.642Z"), ParentId = Guid.Parse("22222222-2222-2222-2222-222222222222"), + AgentId = "agent-1", Data = new AssistantMessageData { MessageId = "msg-1", @@ -134,6 +135,7 @@ public void SessionEvent_ToJson_RoundTrips_JsonElementBackedPayloads(SessionEven switch (expectedType) { case "assistant.message": + Assert.Equal("agent-1", root.GetProperty("agentId").GetString()); Assert.Equal( "README.md", root.GetProperty("data") diff --git a/go/generated_session_events.go b/go/generated_session_events.go index f5b393310..59e5fb19d 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -25,14 +25,16 @@ func (r RawSessionEventData) MarshalJSON() ([]byte, error) { return r.Raw, nil } // SessionEvent represents a single session event with a typed data payload. type SessionEvent struct { - // Unique event identifier (UUID v4), generated when the event is emitted. + // Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + AgentID *string `json:"agentId,omitempty"` + // When true, the event is transient and not persisted to the session event log on disk + Ephemeral *bool `json:"ephemeral,omitempty"` + // Unique event identifier (UUID v4), generated when the event is emitted ID string `json:"id"` - // ISO 8601 timestamp when the event was created. - Timestamp time.Time `json:"timestamp"` - // ID of the preceding event in the session. Null for the first event. + // ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. ParentID *string `json:"parentId"` - // When true, the event is transient and not persisted. - Ephemeral *bool `json:"ephemeral,omitempty"` + // ISO 8601 timestamp when the event was created + Timestamp time.Time `json:"timestamp"` // The event type discriminator. Type SessionEventType `json:"type"` // Typed event payload. Use a type switch to access per-event fields. @@ -53,10 +55,11 @@ func (r *SessionEvent) Marshal() ([]byte, error) { func (e *SessionEvent) UnmarshalJSON(data []byte) error { type rawEvent struct { + AgentID *string `json:"agentId,omitempty"` + Ephemeral *bool `json:"ephemeral,omitempty"` ID string `json:"id"` - Timestamp time.Time `json:"timestamp"` ParentID *string `json:"parentId"` - Ephemeral *bool `json:"ephemeral,omitempty"` + Timestamp time.Time `json:"timestamp"` Type SessionEventType `json:"type"` Data json.RawMessage `json:"data"` } @@ -64,10 +67,11 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &raw); err != nil { return err } + e.AgentID = raw.AgentID + e.Ephemeral = raw.Ephemeral e.ID = raw.ID - e.Timestamp = raw.Timestamp e.ParentID = raw.ParentID - e.Ephemeral = raw.Ephemeral + e.Timestamp = raw.Timestamp e.Type = raw.Type switch raw.Type { @@ -547,18 +551,20 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { func (e SessionEvent) MarshalJSON() ([]byte, error) { type rawEvent struct { + AgentID *string `json:"agentId,omitempty"` + Ephemeral *bool `json:"ephemeral,omitempty"` ID string `json:"id"` - Timestamp time.Time `json:"timestamp"` ParentID *string `json:"parentId"` - Ephemeral *bool `json:"ephemeral,omitempty"` + Timestamp time.Time `json:"timestamp"` Type SessionEventType `json:"type"` Data any `json:"data"` } return json.Marshal(rawEvent{ + AgentID: e.AgentID, + Ephemeral: e.Ephemeral, ID: e.ID, - Timestamp: e.Timestamp, ParentID: e.ParentID, - Ephemeral: e.Ephemeral, + Timestamp: e.Timestamp, Type: e.Type, Data: e.Data, }) diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go new file mode 100644 index 000000000..bf4846570 --- /dev/null +++ b/go/session_event_serialization_test.go @@ -0,0 +1,78 @@ +package copilot + +import ( + "encoding/json" + "testing" +) + +func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { + event, err := UnmarshalSessionEvent([]byte(`{ + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "agentId": "agent-1", + "type": "user.message", + "data": { + "content": "Hello" + } + }`)) + if err != nil { + t.Fatalf("failed to unmarshal session event: %v", err) + } + + if event.AgentID == nil || *event.AgentID != "agent-1" { + t.Fatalf("expected agent ID to round-trip, got %v", event.AgentID) + } + if _, ok := event.Data.(*UserMessageData); !ok { + t.Fatalf("expected user message data, got %T", event.Data) + } + + data, err := event.Marshal() + if err != nil { + t.Fatalf("failed to marshal session event: %v", err) + } + + var serialized map[string]any + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("failed to unmarshal serialized session event: %v", err) + } + if serialized["agentId"] != "agent-1" { + t.Fatalf("expected serialized agentId to round-trip, got %v", serialized["agentId"]) + } +} + +func TestSessionEventAgentIDRoundTripsUnknownEvent(t *testing.T) { + event, err := UnmarshalSessionEvent([]byte(`{ + "id": "00000000-0000-0000-0000-000000000002", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "agentId": "future-agent", + "type": "future.feature_from_server", + "data": { + "key": "value" + } + }`)) + if err != nil { + t.Fatalf("failed to unmarshal session event: %v", err) + } + + if event.AgentID == nil || *event.AgentID != "future-agent" { + t.Fatalf("expected agent ID to round-trip, got %v", event.AgentID) + } + if _, ok := event.Data.(*RawSessionEventData); !ok { + t.Fatalf("expected raw session event data, got %T", event.Data) + } + + data, err := event.Marshal() + if err != nil { + t.Fatalf("failed to marshal session event: %v", err) + } + + var serialized map[string]any + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("failed to unmarshal serialized session event: %v", err) + } + if serialized["agentId"] != "future-agent" { + t.Fatalf("expected serialized agentId to round-trip, got %v", serialized["agentId"]) + } +} diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index a99969fc1..1ef8d9edd 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -4773,6 +4773,7 @@ class SessionEvent: id: UUID timestamp: datetime type: SessionEventType + agent_id: str | None = None ephemeral: bool | None = None parent_id: UUID | None = None raw_type: str | None = None @@ -4782,10 +4783,11 @@ def from_dict(obj: Any) -> "SessionEvent": assert isinstance(obj, dict) raw_type = from_str(obj.get("type")) event_type = SessionEventType(raw_type) - event_id = from_uuid(obj.get("id")) - timestamp = from_datetime(obj.get("timestamp")) - ephemeral = from_union([from_bool, from_none], obj.get("ephemeral")) + agent_id = from_union([from_none, from_str], obj.get("agentId")) + ephemeral = from_union([from_none, from_bool], obj.get("ephemeral")) + id = from_uuid(obj.get("id")) parent_id = from_union([from_none, from_uuid], obj.get("parentId")) + timestamp = from_datetime(obj.get("timestamp")) data_obj = obj.get("data") match event_type: case SessionEventType.SESSION_START: data = SessionStartData.from_dict(data_obj) @@ -4869,9 +4871,10 @@ def from_dict(obj: Any) -> "SessionEvent": case _: data = RawSessionEventData.from_dict(data_obj) return SessionEvent( data=data, - id=event_id, + id=id, timestamp=timestamp, type=event_type, + agent_id=agent_id, ephemeral=ephemeral, parent_id=parent_id, raw_type=raw_type if event_type == SessionEventType.UNKNOWN else None, @@ -4883,8 +4886,10 @@ def to_dict(self) -> dict: result["id"] = to_uuid(self.id) result["timestamp"] = to_datetime(self.timestamp) result["type"] = self.raw_type if self.type == SessionEventType.UNKNOWN and self.raw_type is not None else to_enum(SessionEventType, self.type) + if self.agent_id is not None: + result["agentId"] = from_union([from_none, from_str], self.agent_id) if self.ephemeral is not None: - result["ephemeral"] = from_bool(self.ephemeral) + result["ephemeral"] = from_union([from_none, from_bool], self.ephemeral) result["parentId"] = from_union([from_none, to_uuid], self.parent_id) return result diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 733a9b24b..10ba0644a 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -24,6 +24,7 @@ UserMessageAgentMode, UserMessageAttachmentGithubReferenceType, session_event_from_dict, + session_event_to_dict, ) @@ -47,6 +48,39 @@ def test_unknown_event_type_maps_to_unknown(self): event = session_event_from_dict(unknown_event) assert event.type == SessionEventType.UNKNOWN, f"Expected UNKNOWN, got {event.type}" + def test_known_event_preserves_top_level_agent_id(self): + """Known events should preserve the top-level sub-agent envelope ID.""" + known_event = { + "id": str(uuid4()), + "timestamp": datetime.now().isoformat(), + "parentId": None, + "agentId": "agent-1", + "type": "user.message", + "data": {"content": "Hello"}, + } + + event = session_event_from_dict(known_event) + assert event.agent_id == "agent-1" + assert session_event_to_dict(event)["agentId"] == "agent-1" + + def test_unknown_event_preserves_top_level_agent_id(self): + """Unknown events should preserve the top-level sub-agent envelope ID.""" + unknown_event = { + "id": str(uuid4()), + "timestamp": datetime.now().isoformat(), + "parentId": None, + "agentId": "future-agent", + "type": "session.future_feature_from_server", + "data": {"key": "value"}, + } + + event = session_event_from_dict(unknown_event) + assert event.type == SessionEventType.UNKNOWN + assert event.agent_id == "future-agent" + serialized = session_event_to_dict(event) + assert serialized["agentId"] == "future-agent" + assert serialized["type"] == "session.future_feature_from_server" + def test_malformed_uuid_raises_error(self): """Malformed UUIDs should raise ValueError for visibility, not be suppressed.""" malformed_event = { diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 9c8332c09..6853bde57 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -31,10 +31,13 @@ import { isObjectSchema, isVoidSchema, getNullableInner, + getSessionEventVariantSchemas, + getSharedSessionEventEnvelopeProperties, REPO_ROOT, type ApiSchema, type DefinitionCollections, type RpcMethod, + type SessionEventEnvelopeProperty, } from "./utils.js"; const execFileAsync = promisify(execFile); @@ -343,26 +346,16 @@ function getOrCreateEnum(parentClassName: string, propName: string, values: stri function extractEventVariants(schema: JSONSchema7): EventVariant[] { const definitionCollections = collectDefinitionCollections(schema as Record); - const sessionEvent = - resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? - resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); - if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); - - return sessionEvent.anyOf + return getSessionEventVariantSchemas(schema, definitionCollections) .map((variant) => { - const resolvedVariant = - resolveObjectSchema(variant as JSONSchema7, definitionCollections) ?? - resolveSchema(variant as JSONSchema7, definitionCollections) ?? - (variant as JSONSchema7); - if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) throw new Error("Invalid variant"); - const typeSchema = resolvedVariant.properties.type as JSONSchema7; + const typeSchema = variant.properties!.type as JSONSchema7; const typeName = typeSchema?.const as string; if (!typeName) throw new Error("Variant must have type.const"); const baseName = typeToClassName(typeName); const dataSchema = - resolveObjectSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? - resolveSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? - (resolvedVariant.properties.data as JSONSchema7); + resolveObjectSchema(variant.properties!.data as JSONSchema7, definitionCollections) ?? + resolveSchema(variant.properties!.data as JSONSchema7, definitionCollections) ?? + (variant.properties!.data as JSONSchema7); return { typeName, className: `${baseName}Event`, @@ -678,6 +671,35 @@ function generateDataClass(variant: EventVariant, knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string[] { + const csharpName = toPascalCase(property.name); + const csharpType = resolveSessionPropertyType( + property.schema, + "SessionEvent", + csharpName, + property.required, + knownTypes, + nestedClasses, + enumOutput + ); + const lines: string[] = []; + + lines.push(...xmlDocPropertyComment(property.schema.description, property.name, " ")); + lines.push(...emitDataAnnotations(property.schema, " ")); + if (isSchemaDeprecated(property.schema)) lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isDurationProperty(property.schema)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); + if (!property.required) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${property.name}")]`); + lines.push(` public ${csharpType} ${csharpName} { get; set; }`, ""); + + return lines; +} + function generateSessionEventsCode(schema: JSONSchema7): string { generatedEnums.clear(); sessionDefinitions = collectDefinitionCollections(schema as Record); @@ -685,23 +707,7 @@ function generateSessionEventsCode(schema: JSONSchema7): string { const knownTypes = new Map(); const nestedClasses = new Map(); const enumOutput: string[] = []; - - // Extract descriptions for base class properties from the first variant - const sessionEventDefinition = - resolveSchema({ $ref: "#/definitions/SessionEvent" }, sessionDefinitions) ?? - resolveSchema({ $ref: "#/$defs/SessionEvent" }, sessionDefinitions); - const firstVariant = - typeof sessionEventDefinition === "object" ? (sessionEventDefinition.anyOf?.[0] as JSONSchema7 | undefined) : undefined; - const resolvedFirstVariant = - resolveObjectSchema(firstVariant, sessionDefinitions) ?? - resolveSchema(firstVariant, sessionDefinitions) ?? - firstVariant; - const baseProps = - typeof resolvedFirstVariant === "object" && resolvedFirstVariant?.properties ? resolvedFirstVariant.properties : {}; - const baseDesc = (name: string) => { - const prop = baseProps[name]; - return typeof prop === "object" ? (prop as JSONSchema7).description : undefined; - }; + const envelopeProperties = getSharedSessionEventEnvelopeProperties(schema, sessionDefinitions); const lines: string[] = []; lines.push(`${COPYRIGHT} @@ -731,14 +737,9 @@ namespace GitHub.Copilot.SDK; lines.push(`[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]`); } lines.push(`public partial class SessionEvent`, `{`); - lines.push(...xmlDocComment(baseDesc("id"), " ")); - lines.push(` [JsonPropertyName("id")]`, ` public Guid Id { get; set; }`, ""); - lines.push(...xmlDocComment(baseDesc("timestamp"), " ")); - lines.push(` [JsonPropertyName("timestamp")]`, ` public DateTimeOffset Timestamp { get; set; }`, ""); - lines.push(...xmlDocComment(baseDesc("parentId"), " ")); - lines.push(` [JsonPropertyName("parentId")]`, ` public Guid? ParentId { get; set; }`, ""); - lines.push(...xmlDocComment(baseDesc("ephemeral"), " ")); - lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, ` [JsonPropertyName("ephemeral")]`, ` public bool? Ephemeral { get; set; }`, ""); + for (const property of envelopeProperties) { + lines.push(...emitSessionEventEnvelopeProperty(property, knownTypes, nestedClasses, enumOutput)); + } lines.push(` /// `, ` /// The event type discriminator.`, ` /// `); lines.push(` [JsonIgnore]`, ` public virtual string Type => "unknown";`, ""); lines.push(` /// Deserializes a JSON string into a .`); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index c1acc4980..3a4b76cbc 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -32,9 +32,12 @@ import { withSharedDefinitions, refTypeName, resolveRef, + getSessionEventVariantSchemas, + getSharedSessionEventEnvelopeProperties, type ApiSchema, type DefinitionCollections, type RpcMethod, + type SessionEventEnvelopeProperty, } from "./utils.js"; const execFileAsync = promisify(execFile); @@ -305,6 +308,13 @@ interface GoEventVariant { dataDescription?: string; } +interface GoEventEnvelopeProperty extends SessionEventEnvelopeProperty { + fieldName: string; + typeName: string; + jsonTag: string; + description?: string; +} + interface GoCodegenCtx { structs: string[]; enums: string[]; @@ -315,25 +325,15 @@ interface GoCodegenCtx { function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { const definitionCollections = collectDefinitionCollections(schema as Record); - const sessionEvent = - resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? - resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); - if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); - - return (sessionEvent.anyOf as JSONSchema7[]) + return getSessionEventVariantSchemas(schema, definitionCollections) .map((variant) => { - const resolvedVariant = - resolveObjectSchema(variant as JSONSchema7, definitionCollections) ?? - resolveSchema(variant as JSONSchema7, definitionCollections) ?? - (variant as JSONSchema7); - if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) throw new Error("Invalid variant"); - const typeSchema = resolvedVariant.properties.type as JSONSchema7; + const typeSchema = variant.properties!.type as JSONSchema7; const typeName = typeSchema?.const as string; if (!typeName) throw new Error("Variant must have type.const"); const dataSchema = - resolveObjectSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? - resolveSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? - ((resolvedVariant.properties.data as JSONSchema7) || {}); + resolveObjectSchema(variant.properties!.data as JSONSchema7, definitionCollections) ?? + resolveSchema(variant.properties!.data as JSONSchema7, definitionCollections) ?? + ((variant.properties!.data as JSONSchema7) || {}); return { typeName, dataClassName: `${toPascalCase(typeName)}Data`, @@ -343,6 +343,36 @@ function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { }); } +function getGoSharedEventEnvelopeProperties(schema: JSONSchema7, ctx: GoCodegenCtx): GoEventEnvelopeProperty[] { + return getSharedSessionEventEnvelopeProperties(schema, ctx.definitions) + .map((property) => { + const { name, schema, required } = property; + const typeName = resolveGoPropertyType(schema, "SessionEvent", name, required && !getNullableInner(schema), ctx); + const omit = required ? "" : ",omitempty"; + + return { + name, + schema, + required, + fieldName: toGoFieldName(name), + typeName, + jsonTag: `json:"${name}${omit}"`, + description: schema.description, + }; + }); +} + +function emitGoEnvelopeStructField(property: GoEventEnvelopeProperty, includeComment: boolean): string[] { + const lines: string[] = []; + if (includeComment && property.description) { + for (const line of property.description.split(/\r?\n/)) { + lines.push(`\t// ${line}`); + } + } + lines.push(`\t${property.fieldName} ${property.typeName} \`${property.jsonTag}\``); + return lines; +} + /** * Find a const-valued discriminator property shared by all anyOf variants. */ @@ -736,6 +766,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { generatedNames: new Set(), definitions: collectDefinitionCollections(schema as Record), }; + const envelopeProperties = getGoSharedEventEnvelopeProperties(schema, ctx); // Generate per-event data structs const dataStructs: string[] = []; @@ -834,15 +865,9 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // SessionEvent struct out.push(`// SessionEvent represents a single session event with a typed data payload.`); out.push(`type SessionEvent struct {`); - out.push(`\t// Unique event identifier (UUID v4), generated when the event is emitted.`); - out.push(`\tID string \`json:"id"\``); - out.push(`\t// ISO 8601 timestamp when the event was created.`); - out.push(`\tTimestamp time.Time \`json:"timestamp"\``); - // parentId: string or null - out.push(`\t// ID of the preceding event in the session. Null for the first event.`); - out.push(`\tParentID *string \`json:"parentId"\``); - out.push(`\t// When true, the event is transient and not persisted.`); - out.push(`\tEphemeral *bool \`json:"ephemeral,omitempty"\``); + for (const property of envelopeProperties) { + out.push(...emitGoEnvelopeStructField(property, true)); + } out.push(`\t// The event type discriminator.`); out.push(`\tType SessionEventType \`json:"type"\``); out.push(`\t// Typed event payload. Use a type switch to access per-event fields.`); @@ -869,10 +894,11 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // Custom UnmarshalJSON out.push(`func (e *SessionEvent) UnmarshalJSON(data []byte) error {`); out.push(`\ttype rawEvent struct {`); - out.push(`\t\tID string \`json:"id"\``); - out.push(`\t\tTimestamp time.Time \`json:"timestamp"\``); - out.push(`\t\tParentID *string \`json:"parentId"\``); - out.push(`\t\tEphemeral *bool \`json:"ephemeral,omitempty"\``); + for (const property of envelopeProperties) { + for (const line of emitGoEnvelopeStructField(property, false)) { + out.push(`\t${line}`); + } + } out.push(`\t\tType SessionEventType \`json:"type"\``); out.push(`\t\tData json.RawMessage \`json:"data"\``); out.push(`\t}`); @@ -880,10 +906,9 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { out.push(`\tif err := json.Unmarshal(data, &raw); err != nil {`); out.push(`\t\treturn err`); out.push(`\t}`); - out.push(`\te.ID = raw.ID`); - out.push(`\te.Timestamp = raw.Timestamp`); - out.push(`\te.ParentID = raw.ParentID`); - out.push(`\te.Ephemeral = raw.Ephemeral`); + for (const property of envelopeProperties) { + out.push(`\te.${property.fieldName} = raw.${property.fieldName}`); + } out.push(`\te.Type = raw.Type`); out.push(``); out.push(`\tswitch raw.Type {`); @@ -915,18 +940,18 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // Custom MarshalJSON out.push(`func (e SessionEvent) MarshalJSON() ([]byte, error) {`); out.push(`\ttype rawEvent struct {`); - out.push(`\t\tID string \`json:"id"\``); - out.push(`\t\tTimestamp time.Time \`json:"timestamp"\``); - out.push(`\t\tParentID *string \`json:"parentId"\``); - out.push(`\t\tEphemeral *bool \`json:"ephemeral,omitempty"\``); + for (const property of envelopeProperties) { + for (const line of emitGoEnvelopeStructField(property, false)) { + out.push(`\t${line}`); + } + } out.push(`\t\tType SessionEventType \`json:"type"\``); out.push(`\t\tData any \`json:"data"\``); out.push(`\t}`); out.push(`\treturn json.Marshal(rawEvent{`); - out.push(`\t\tID: e.ID,`); - out.push(`\t\tTimestamp: e.Timestamp,`); - out.push(`\t\tParentID: e.ParentID,`); - out.push(`\t\tEphemeral: e.Ephemeral,`); + for (const property of envelopeProperties) { + out.push(`\t\t${property.fieldName}: e.${property.fieldName},`); + } out.push(`\t\tType: e.Type,`); out.push(`\t\tData: e.Data,`); out.push(`\t})`); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 6a3fe3b7d..a4cbcd6d4 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -31,9 +31,12 @@ import { resolveObjectSchema, resolveSchema, withSharedDefinitions, + getSessionEventVariantSchemas, + getSharedSessionEventEnvelopeProperties, type ApiSchema, type DefinitionCollections, type RpcMethod, + type SessionEventEnvelopeProperty, } from "./utils.js"; // ── Utilities ─────────────────────────────────────────────────────────────── @@ -475,6 +478,13 @@ interface PyEventVariant { dataDescription?: string; } +interface PyEventEnvelopeProperty extends SessionEventEnvelopeProperty { + jsonName: string; + fieldName: string; + hasDefault: boolean; + resolved: PyResolvedType; +} + interface PyResolvedType { annotation: string; fromExpr: (expr: string) => string; @@ -621,33 +631,18 @@ function toPythonLiteral(value: unknown): string | undefined { function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] { const definitionCollections = collectDefinitionCollections(schema as Record); - const sessionEvent = - resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? - resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); - if (!sessionEvent?.anyOf) { - throw new Error("Schema must have SessionEvent definition with anyOf"); - } - - return (sessionEvent.anyOf as JSONSchema7[]) + return getSessionEventVariantSchemas(schema, definitionCollections) .map((variant) => { - const resolvedVariant = - resolveObjectSchema(variant as JSONSchema7, definitionCollections) ?? - resolveSchema(variant as JSONSchema7, definitionCollections) ?? - (variant as JSONSchema7); - if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) { - throw new Error("Invalid event variant"); - } - - const typeSchema = resolvedVariant.properties.type as JSONSchema7; + const typeSchema = variant.properties!.type as JSONSchema7; const typeName = typeSchema?.const as string; if (!typeName) { throw new Error("Event variant must define type.const"); } const dataSchema = - resolveObjectSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? - resolveSchema(resolvedVariant.properties.data as JSONSchema7, definitionCollections) ?? - ((resolvedVariant.properties.data as JSONSchema7) || {}); + resolveObjectSchema(variant.properties!.data as JSONSchema7, definitionCollections) ?? + resolveSchema(variant.properties!.data as JSONSchema7, definitionCollections) ?? + ((variant.properties!.data as JSONSchema7) || {}); return { typeName, dataClassName: `${toPascalCase(typeName)}Data`, @@ -657,6 +652,23 @@ function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] { }); } +function getPySharedEventEnvelopeProperties(schema: JSONSchema7, ctx: PyCodegenCtx): PyEventEnvelopeProperty[] { + return getSharedSessionEventEnvelopeProperties(schema, ctx.definitions) + .map((property) => { + const { name, schema, required } = property; + const resolved = resolvePyPropertyType(schema, "SessionEvent", name, required, ctx); + + return { + ...property, + jsonName: name, + fieldName: toSnakeCase(name), + required, + hasDefault: !required || resolved.annotation.includes(" | None"), + resolved, + }; + }); +} + function findPyDiscriminator( variants: JSONSchema7[] ): { property: string; mapping: Map } | null { @@ -1267,6 +1279,9 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { for (const variant of variants) { emitPyClass(variant.dataClassName, variant.dataSchema, ctx, variant.dataDescription); } + const envelopeProperties = getPySharedEventEnvelopeProperties(schema, ctx); + const envelopePropertiesWithoutDefaults = envelopeProperties.filter((property) => !property.hasDefault); + const envelopePropertiesWithDefaults = envelopeProperties.filter((property) => property.hasDefault); const eventTypeLines: string[] = []; eventTypeLines.push(`class SessionEventType(Enum):`); @@ -1507,11 +1522,13 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { out.push(`@dataclass`); out.push(`class SessionEvent:`); out.push(` data: SessionEventData`); - out.push(` id: UUID`); - out.push(` timestamp: datetime`); + for (const property of envelopePropertiesWithoutDefaults) { + out.push(` ${property.fieldName}: ${property.resolved.annotation}`); + } out.push(` type: SessionEventType`); - out.push(` ephemeral: bool | None = None`); - out.push(` parent_id: UUID | None = None`); + for (const property of envelopePropertiesWithDefaults) { + out.push(` ${property.fieldName}: ${property.resolved.annotation} = None`); + } out.push(` raw_type: str | None = None`); out.push(``); out.push(` @staticmethod`); @@ -1519,10 +1536,9 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { out.push(` assert isinstance(obj, dict)`); out.push(` raw_type = from_str(obj.get("type"))`); out.push(` event_type = SessionEventType(raw_type)`); - out.push(` event_id = from_uuid(obj.get("id"))`); - out.push(` timestamp = from_datetime(obj.get("timestamp"))`); - out.push(` ephemeral = from_union([from_bool, from_none], obj.get("ephemeral"))`); - out.push(` parent_id = from_union([from_none, from_uuid], obj.get("parentId"))`); + for (const property of envelopeProperties) { + out.push(` ${property.fieldName} = ${property.resolved.fromExpr(`obj.get(${JSON.stringify(property.jsonName)})`)}`); + } out.push(` data_obj = obj.get("data")`); out.push(` match event_type:`); for (const variant of variants) { @@ -1533,25 +1549,34 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { out.push(` case _: data = RawSessionEventData.from_dict(data_obj)`); out.push(` return SessionEvent(`); out.push(` data=data,`); - out.push(` id=event_id,`); - out.push(` timestamp=timestamp,`); + for (const property of envelopePropertiesWithoutDefaults) { + out.push(` ${property.fieldName}=${property.fieldName},`); + } out.push(` type=event_type,`); - out.push(` ephemeral=ephemeral,`); - out.push(` parent_id=parent_id,`); + for (const property of envelopePropertiesWithDefaults) { + out.push(` ${property.fieldName}=${property.fieldName},`); + } out.push(` raw_type=raw_type if event_type == SessionEventType.UNKNOWN else None,`); out.push(` )`); out.push(``); out.push(` def to_dict(self) -> dict:`); out.push(` result: dict = {}`); out.push(` result["data"] = self.data.to_dict()`); - out.push(` result["id"] = to_uuid(self.id)`); - out.push(` result["timestamp"] = to_datetime(self.timestamp)`); + for (const property of envelopePropertiesWithoutDefaults) { + out.push(` result[${JSON.stringify(property.jsonName)}] = ${property.resolved.toExpr(`self.${property.fieldName}`)}`); + } out.push( ` result["type"] = self.raw_type if self.type == SessionEventType.UNKNOWN and self.raw_type is not None else to_enum(SessionEventType, self.type)` ); - out.push(` if self.ephemeral is not None:`); - out.push(` result["ephemeral"] = from_bool(self.ephemeral)`); - out.push(` result["parentId"] = from_union([from_none, to_uuid], self.parent_id)`); + for (const property of envelopePropertiesWithDefaults) { + const valueExpr = property.resolved.toExpr(`self.${property.fieldName}`); + if (property.required) { + out.push(` result[${JSON.stringify(property.jsonName)}] = ${valueExpr}`); + } else { + out.push(` if self.${property.fieldName} is not None:`); + out.push(` result[${JSON.stringify(property.jsonName)}] = ${valueExpr}`); + } + } out.push(` return result`); out.push(``); out.push(``); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 4a4c31f3f..16aaa0bfe 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -29,6 +29,12 @@ export interface DefinitionCollections { $defs?: Record; } +export interface SessionEventEnvelopeProperty { + name: string; + schema: JSONSchema7; + required: boolean; +} + export interface JSONSchema7WithDefs extends JSONSchema7, DefinitionCollections {} export type SchemaWithSharedDefinitions = T & { @@ -497,6 +503,66 @@ export function resolveObjectSchema( return resolved; } +export function getSessionEventVariantSchemas( + schema: JSONSchema7, + definitionCollections: DefinitionCollections = collectDefinitionCollections(schema as Record) +): JSONSchema7[] { + const sessionEvent = + resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? + resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections); + if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); + + return (sessionEvent.anyOf as JSONSchema7[]).map((variant) => { + const resolvedVariant = + resolveObjectSchema(variant, definitionCollections) ?? + resolveSchema(variant, definitionCollections) ?? + variant; + if (typeof resolvedVariant !== "object" || !resolvedVariant.properties) throw new Error("Invalid event variant"); + return resolvedVariant; + }); +} + +export function getSharedSessionEventEnvelopeProperties( + schema: JSONSchema7, + definitionCollections: DefinitionCollections = collectDefinitionCollections(schema as Record) +): SessionEventEnvelopeProperty[] { + const variants = getSessionEventVariantSchemas(schema, definitionCollections); + const firstVariant = variants[0]; + const firstProperties = firstVariant.properties ?? {}; + + return Object.entries(firstProperties) + .filter(([name]) => name !== "type" && name !== "data") + .map(([name]) => { + const propertySchemas = variants + .map((variant) => variant.properties?.[name]) + .filter((propSchema): propSchema is JSONSchema7 => typeof propSchema === "object" && propSchema !== null); + + if (propertySchemas.length !== variants.length) return undefined; + + return { + name, + schema: selectSessionEventEnvelopePropertySchema(propertySchemas), + required: variants.every((variant) => (variant.required ?? []).includes(name)), + }; + }) + .filter((property): property is SessionEventEnvelopeProperty => property !== undefined); +} + +function selectSessionEventEnvelopePropertySchema(propertySchemas: JSONSchema7[]): JSONSchema7 { + // Some variants further constrain a shared envelope property, e.g. ephemeral const true. + // Generate the base property from the least restrictive schema that has useful metadata. + return ( + propertySchemas.find((schema) => !isConstOrEnumSchema(schema) && schema.description) ?? + propertySchemas.find((schema) => !isConstOrEnumSchema(schema)) ?? + propertySchemas.find((schema) => schema.description) ?? + propertySchemas[0] + ); +} + +function isConstOrEnumSchema(schema: JSONSchema7): boolean { + return "const" in schema || (Array.isArray(schema.enum) && schema.enum.length > 0); +} + export function hasSchemaPayload(schema: JSONSchema7 | null | undefined): boolean { if (!schema) return false; if (schema.properties) return Object.keys(schema.properties).length > 0;