Skip to content

Commit cdd68e3

Browse files
committed
chore: bump ACP schema to v0.6.2
Updates the ACP schema from v0.4.9 to v0.6.2, incorporating latest protocol changes and type definitions. Enhances code generation with smart nested type naming that applies multiple heuristics to create idiomatic Go type names while preventing collisions. Key improvements: - Implements word-boundary deduplication for nested types - Strips RPC suffixes (Request/Response/Notification) when generating nested type names for cleaner identifiers - Adds defensive programming with panic-based collision detection to catch codegen bugs early - Generates proper nested structs for inline object properties Breaking changes: - Renames ToolCallUpdate to RequestPermissionToolCall in permission request contexts for schema consistency - Renames SessionUpdateToolCallUpdate to SessionToolCallUpdate - Changes EmbeddedResource.Resource field to direct EmbeddedResourceResource type
1 parent 2c1aef2 commit cdd68e3

File tree

12 files changed

+1092
-453
lines changed

12 files changed

+1092
-453
lines changed

.claude/settings.local.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(jj log:*)"
5+
],
6+
"deny": [],
7+
"ask": []
8+
}
9+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Learn more about the protocol itself at <https://agentclientprotocol.com>.
1414
<!-- `$ printf 'go get github.com/coder/acp-go-sdk@v%s\n' "$(cat schema/version)"` as bash -->
1515

1616
```bash
17-
go get github.com/coder/acp-go-sdk@v0.4.9
17+
go get github.com/coder/acp-go-sdk@v0.6.2
1818
```
1919

2020
## Get Started

acp_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ func TestConnectionHandlesMessageOrdering(t *testing.T) {
352352
}
353353
if _, err := as.RequestPermission(context.Background(), RequestPermissionRequest{
354354
SessionId: "test-session",
355-
ToolCall: ToolCallUpdate{
355+
ToolCall: RequestPermissionToolCall{
356356
Title: Ptr("Execute command"),
357357
Kind: ptr(ToolKindExecute),
358358
Status: ptr(ToolCallStatusPending),

cmd/generate/internal/emit/types.go

Lines changed: 193 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
19100
func 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()

example/agent/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ func (a *exampleAgent) simulateTurn(ctx context.Context, sid string) error {
205205
// request permission for sensitive operation
206206
permResp, err := a.conn.RequestPermission(ctx, acp.RequestPermissionRequest{
207207
SessionId: acp.SessionId(sid),
208-
ToolCall: acp.ToolCallUpdate{
208+
ToolCall: acp.RequestPermissionToolCall{
209209
ToolCallId: acp.ToolCallId("call_2"),
210210
Title: acp.Ptr("Modifying critical configuration file"),
211211
Kind: acp.Ptr(acp.ToolKindEdit),

example_agent_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (a *agentExample) Prompt(ctx context.Context, p PromptRequest) (PromptRespo
5757
// Ask the client for permission to proceed with the change.
5858
resp, _ := a.conn.RequestPermission(ctx, RequestPermissionRequest{
5959
SessionId: p.SessionId,
60-
ToolCall: ToolCallUpdate{
60+
ToolCall: RequestPermissionToolCall{
6161
ToolCallId: ToolCallId("call_1"),
6262
Title: Ptr("Modifying configuration"),
6363
Kind: Ptr(ToolKindEdit),

0 commit comments

Comments
 (0)