@@ -9,17 +9,104 @@ import (
99 "slices"
1010 "sort"
1111 "strings"
12+ "unicode"
1213
1314 "github.com/coder/acp-go-sdk/cmd/generate/internal/ir"
1415 "github.com/coder/acp-go-sdk/cmd/generate/internal/load"
1516 "github.com/coder/acp-go-sdk/cmd/generate/internal/util"
1617)
1718
19+ // splitCamelCase splits a CamelCase string into words.
20+ // Example: "RequestPermissionRequest" -> ["Request", "Permission", "Request"]
21+ func splitCamelCase (s string ) []string {
22+ if s == "" {
23+ return nil
24+ }
25+ var words []string
26+ var current strings.Builder
27+
28+ for i , r := range s {
29+ if i > 0 && unicode .IsUpper (r ) {
30+ // Start new word on uppercase letter
31+ if current .Len () > 0 {
32+ words = append (words , current .String ())
33+ current .Reset ()
34+ }
35+ }
36+ current .WriteRune (r )
37+ }
38+ if current .Len () > 0 {
39+ words = append (words , current .String ())
40+ }
41+ return words
42+ }
43+
44+ // generateNestedTypeName applies multiple heuristics to create idiomatic nested type names
45+ // while guaranteeing stability (name depends only on parent, field, and collision check).
46+ // Panics if a collision is detected to catch codegen bugs early.
47+ func generateNestedTypeName (parentName , rawFieldName string , usedNames map [string ]bool ) string {
48+ // Heuristic 1: Ensure field name is properly capitalized first
49+ fieldName := util .ToExportedField (rawFieldName )
50+
51+ // Heuristic 2: Try RPC suffix stripping for Request/Response/Notification parents
52+ // This handles cases like RequestPermissionRequest + ToolCall → RequestPermissionToolCall
53+ for _ , suffix := range []string {"Request" , "Response" , "Notification" } {
54+ if strings .HasSuffix (parentName , suffix ) {
55+ baseName := strings .TrimSuffix (parentName , suffix )
56+ if len (baseName ) > 0 {
57+ candidate := baseName + fieldName
58+ if ! usedNames [candidate ] {
59+ return candidate
60+ }
61+ }
62+ }
63+ }
64+
65+ // Heuristic 3: Try word-boundary deduplication
66+ // This handles cases like AgentNotification + ExtNotification → AgentExtNotification
67+ parentWords := splitCamelCase (parentName )
68+ fieldWords := splitCamelCase (fieldName )
69+
70+ // Need at least 2 words in parent to consider deduplication
71+ if len (parentWords ) >= 2 && len (fieldWords ) > 0 {
72+ lastParentWord := parentWords [len (parentWords )- 1 ]
73+ firstFieldWord := fieldWords [0 ]
74+ lastFieldWord := fieldWords [len (fieldWords )- 1 ]
75+
76+ if strings .EqualFold (lastParentWord , firstFieldWord ) || strings .EqualFold (lastParentWord , lastFieldWord ) {
77+ // Remove last word from parent, then concatenate
78+ parentWithoutLast := strings .Join (parentWords [:len (parentWords )- 1 ], "" )
79+ if len (parentWithoutLast ) > 0 {
80+ candidate := parentWithoutLast + fieldName
81+ if ! usedNames [candidate ] {
82+ return candidate
83+ }
84+ }
85+ }
86+ }
87+
88+ // Heuristic 4: Fall back to full concatenation
89+ fullName := parentName + fieldName
90+ if usedNames [fullName ] {
91+ // DEFENSIVE PROGRAMMING: This should never happen if we're tracking names correctly.
92+ // If it does, it indicates a bug in the codegen logic.
93+ panic (fmt .Sprintf ("type name collision detected: %q already exists (parent: %q, field: %q)" ,
94+ fullName , parentName , rawFieldName ))
95+ }
96+ return fullName
97+ }
98+
1899// WriteTypesJen emits go/types_gen.go with all types and the Agent/Client interfaces.
19100func WriteTypesJen (outDir string , schema * load.Schema , meta * load.Meta ) error {
20101 f := NewFile ("acp" )
21102 f .HeaderComment ("Code generated by acp-go-generator; DO NOT EDIT." )
22103
104+ // Track all type names to avoid collisions when generating nested types
105+ usedTypeNames := make (map [string ]bool )
106+ for k := range schema .Defs {
107+ usedTypeNames [k ] = true
108+ }
109+
23110 // Deterministic order
24111 keys := make ([]string , 0 , len (schema .Defs ))
25112 for k := range schema .Defs {
@@ -33,6 +120,16 @@ func WriteTypesJen(outDir string, schema *load.Schema, meta *load.Meta) error {
33120 continue
34121 }
35122
123+ // DEFENSIVE PROGRAMMING: Ensure we're not about to emit a type that conflicts
124+ // with a previously generated nested type. This should never happen since we
125+ // pre-populate usedTypeNames with all schema definitions.
126+ if usedTypeNames [name ] && name != "" {
127+ // This is expected for schema-defined types, but verify it's in the schema
128+ if _ , inSchema := schema .Defs [name ]; ! inSchema {
129+ panic (fmt .Sprintf ("BUG: attempting to emit type %q that was already generated as a nested type" , name ))
130+ }
131+ }
132+
36133 if def .Description != "" {
37134 f .Comment (util .SanitizeComment (def .Description ))
38135 }
@@ -63,11 +160,11 @@ func WriteTypesJen(outDir string, schema *load.Schema, meta *load.Meta) error {
63160 }
64161 f .Line ()
65162 case len (def .AnyOf ) > 0 :
66- emitUnion (f , name , def .AnyOf , false )
163+ emitUnion (f , name , def .AnyOf , false , usedTypeNames )
67164 case len (def .OneOf ) > 0 && ! isStringConstUnion (def ):
68165 // Generic union generation for non-enum oneOf
69166 // Use the same implementation, but require exactly one variant
70- emitUnion (f , name , def .OneOf , true )
167+ emitUnion (f , name , def .OneOf , true , usedTypeNames )
71168 case ir .PrimaryType (def ) == "object" && len (def .Properties ) > 0 :
72169 st := []Code {}
73170 req := map [string ]struct {}{}
@@ -79,6 +176,54 @@ func WriteTypesJen(outDir string, schema *load.Schema, meta *load.Meta) error {
79176 pkeys = append (pkeys , pk )
80177 }
81178 sort .Strings (pkeys )
179+
180+ // Pre-generate nested struct types for inline object properties
181+ nestedTypes := map [string ]string {} // property name -> generated type name
182+ for _ , pk := range pkeys {
183+ prop := def .Properties [pk ]
184+ // Detect inline objects: no $ref, type is object, has properties
185+ if prop .Ref == "" && ir .PrimaryType (prop ) == "object" && len (prop .Properties ) > 0 {
186+ nestedTypeName := generateNestedTypeName (name , pk , usedTypeNames )
187+ nestedTypes [pk ] = nestedTypeName
188+
189+ // DEFENSIVE PROGRAMMING: Assert no collision before registering
190+ if usedTypeNames [nestedTypeName ] {
191+ panic (fmt .Sprintf ("BUG: nested type name collision: %q already registered" , nestedTypeName ))
192+ }
193+ usedTypeNames [nestedTypeName ] = true
194+
195+ // Generate the nested struct type
196+ if prop .Description != "" {
197+ f .Comment (util .SanitizeComment (prop .Description ))
198+ }
199+ nestedFields := []Code {}
200+ nestedReq := map [string ]struct {}{}
201+ for _ , r := range prop .Required {
202+ nestedReq [r ] = struct {}{}
203+ }
204+ nestedPkeys := make ([]string , 0 , len (prop .Properties ))
205+ for npk := range prop .Properties {
206+ nestedPkeys = append (nestedPkeys , npk )
207+ }
208+ sort .Strings (nestedPkeys )
209+
210+ for _ , npk := range nestedPkeys {
211+ nprop := prop .Properties [npk ]
212+ nfield := util .ToExportedField (npk )
213+ if nprop .Description != "" {
214+ nestedFields = append (nestedFields , Comment (util .SanitizeComment (nprop .Description )))
215+ }
216+ ntag := npk
217+ if _ , ok := nestedReq [npk ]; ! ok {
218+ ntag = npk + ",omitempty"
219+ }
220+ nestedFields = append (nestedFields , Id (nfield ).Add (jenTypeForOptional (nprop )).Tag (map [string ]string {"json" : ntag }))
221+ }
222+ f .Type ().Id (nestedTypeName ).Struct (nestedFields ... )
223+ f .Line ()
224+ }
225+ }
226+
82227 // Track fields with schema defaults for generic (de)serialization
83228 type DefaultKind int
84229 const (
@@ -142,7 +287,14 @@ func WriteTypesJen(outDir string, schema *load.Schema, meta *load.Meta) error {
142287 }
143288 st = append (st , Comment (util .SanitizeComment (fmt .Sprintf ("Defaults to %s if unset." , dp .defaultJSON ))))
144289 }
145- st = append (st , Id (field ).Add (jenTypeForOptional (prop )).Tag (map [string ]string {"json" : tag }))
290+ // Use generated nested type if available, otherwise use jenTypeForOptional
291+ var fieldType Code
292+ if nestedTypeName , hasNested := nestedTypes [pk ]; hasNested {
293+ fieldType = Id (nestedTypeName )
294+ } else {
295+ fieldType = jenTypeForOptional (prop )
296+ }
297+ st = append (st , Id (field ).Add (fieldType ).Tag (map [string ]string {"json" : tag }))
146298 }
147299 f .Type ().Id (name ).Struct (st ... )
148300 f .Line ()
@@ -544,7 +696,7 @@ func jenTypeForOptional(d *load.Definition) Code {
544696// emitAvailableCommandInputJen generates a concrete variant type for anyOf and a thin union wrapper
545697// that supports JSON unmarshal by probing object shape. Currently the schema defines one variant
546698// (title: UnstructuredCommandInput) with a required 'hint' field.
547- func emitUnion (f * File , name string , defs []* load.Definition , exactlyOne bool ) {
699+ func emitUnion (f * File , name string , defs []* load.Definition , exactlyOne bool , usedTypeNames map [ string ] bool ) {
548700 type variantInfo struct {
549701 fieldName string
550702 typeName string
@@ -586,16 +738,35 @@ func emitUnion(f *File, name string, defs []*load.Definition, exactlyOne bool) {
586738 if v .Ref != "" && strings .HasPrefix (v .Ref , "#/$defs/" ) {
587739 tname = v .Ref [len ("#/$defs/" ):]
588740 } else if v .Title != "" {
589- tname = v .Title
741+ // Scope inline variant titles to parent union name with smart naming
742+ tname = generateNestedTypeName (name , v .Title , usedTypeNames )
743+ // DEFENSIVE PROGRAMMING: Assert no collision before registering
744+ if usedTypeNames [tname ] {
745+ panic (fmt .Sprintf ("BUG: variant type name collision: %q already registered (parent: %q, title: %q)" ,
746+ tname , name , v .Title ))
747+ }
748+ usedTypeNames [tname ] = true
590749 } else {
591750 if discKey != "" {
592751 if pd := v .Properties [discKey ]; pd != nil && pd .Const != nil {
593752 s := fmt .Sprint (pd .Const )
594- tname = name + util .ToExportedField (s )
753+ tname = generateNestedTypeName (name , s , usedTypeNames )
754+ // DEFENSIVE PROGRAMMING: Assert no collision before registering
755+ if usedTypeNames [tname ] {
756+ panic (fmt .Sprintf ("BUG: variant type name collision: %q already registered (parent: %q, discriminator: %q)" ,
757+ tname , name , s ))
758+ }
759+ usedTypeNames [tname ] = true
595760 }
596761 }
597762 if tname == "" {
598763 tname = name + fmt .Sprintf ("Variant%d" , idx + 1 )
764+ // DEFENSIVE PROGRAMMING: Assert no collision before registering
765+ if usedTypeNames [tname ] {
766+ panic (fmt .Sprintf ("BUG: variant type name collision: %q already registered (parent: %q)" ,
767+ tname , name ))
768+ }
769+ usedTypeNames [tname ] = true
599770 }
600771 }
601772 // Ensure Title-derived names are exported (e.g., "stdio" -> "Stdio").
@@ -610,8 +781,9 @@ func emitUnion(f *File, name string, defs []*load.Definition, exactlyOne bool) {
610781 }
611782 }
612783 isObj := len (v .Properties ) > 0
613- // Skip phantom variants that have neither $ref nor object shape nor null (e.g., placeholders with only a title)
614- if ! isObj && v .Ref == "" && ! isNull {
784+ // Skip phantom variants that have neither $ref nor object shape nor null nor title
785+ // (but allow title-only variants like ExtMethodRequest to generate as empty structs)
786+ if ! isObj && v .Ref == "" && ! isNull && v .Title == "" {
615787 continue
616788 }
617789 // collect const properties (e.g., type, outcome)
@@ -623,9 +795,15 @@ func emitUnion(f *File, name string, defs []*load.Definition, exactlyOne bool) {
623795 }
624796 }
625797 }
626- if (isObj || isNull ) && v .Ref == "" {
798+ // Emit struct for inline variants (non-$ref)
799+ if (isObj || isNull || v .Title != "" ) && v .Ref == "" {
800+ // DEFENSIVE PROGRAMMING: Verify tname is registered before emitting
801+ if ! usedTypeNames [tname ] {
802+ panic (fmt .Sprintf ("BUG: attempting to emit unregistered type: %q (parent union: %q)" , tname , name ))
803+ }
804+
627805 st := []Code {}
628- if ! isNull {
806+ if ! isNull && isObj {
629807 req := map [string ]struct {}{}
630808 for _ , r := range v .Required {
631809 req [r ] = struct {}{}
@@ -650,6 +828,11 @@ func emitUnion(f *File, name string, defs []*load.Definition, exactlyOne bool) {
650828 }
651829 st = append (st , Id (field ).Add (jenTypeForOptional (pDef )).Tag (map [string ]string {"json" : tag }))
652830 }
831+ } else if ! isNull && ! isObj && v .Title != "" {
832+ // Title-only extension types: emit description as comment
833+ if v .Description != "" {
834+ f .Comment (util .SanitizeComment (v .Description ))
835+ }
653836 }
654837 f .Type ().Id (tname ).Struct (st ... )
655838 f .Line ()
0 commit comments