| 1 | package protocolgen |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "go/format" |
| 7 | "reflect" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/extension/protocol" |
| 12 | ) |
| 13 | |
| 14 | // The SDK types artifact (SDKTypesArtifactPath in generate.go) is produced |
| 15 | // from the same frozen registry and reflection walk as the JSON Schema: |
| 16 | // every wire DTO and enum reachable from the registry, plus the payload |
| 17 | // documents addressed indirectly through json.RawMessage fields and the |
| 18 | // structured error envelope. |
| 19 | |
| 20 | // sdkEnumPrefixes maps every reachable string enum type to the constant-name |
| 21 | // prefix its values carry in the SDK's public API. ProviderErrorCode has |
| 22 | // none: its values already read as Go names ("provider_failed" → |
| 23 | // ProviderFailed). |
| 24 | var sdkEnumPrefixes = map[string]string{ |
| 25 | "InterceptEvent": "Event", |
| 26 | "InterceptDecision": "Decision", |
| 27 | "UIHostKind": "UIHost", |
| 28 | "UISurfaceKind": "UISurface", |
| 29 | "UIRequestKind": "UIRequest", |
| 30 | "UIFieldKind": "UIField", |
| 31 | "UISeverity": "UISeverity", |
| 32 | "ProviderRole": "ProviderRole", |
| 33 | "ProviderChunkType": "Chunk", |
| 34 | "ProviderErrorCode": "", |
| 35 | "ContentEncoding": "Content", |
| 36 | "ErrorReason": "Err", |
| 37 | } |
| 38 | |
| 39 | // sdkEnumConstantExceptions pins constant names that mechanical mangling |
| 40 | // would render differently from the SDK's established public API. |
| 41 | var sdkEnumConstantExceptions = map[string]map[string]string{ |
| 42 | "ProviderChunkType": {"tool_call_args_delta": "ChunkToolCallDelta"}, |
| 43 | } |
| 44 | |
| 45 | // sdkInitialisms upper-cases identifier parts that read as acronyms. |
| 46 | var sdkInitialisms = map[string]string{ |
| 47 | "ui": "UI", "tui": "TUI", "acp": "ACP", "utf8": "UTF8", |
| 48 | } |
| 49 | |
| 50 | var rawMessageType = reflect.TypeOf(json.RawMessage{}) |
| 51 | |
| 52 | // sdkTypeWalk is the deterministic first-visit record of every named wire |
| 53 | // type reachable from the frozen registry (plus the extra roots below). |
| 54 | type sdkTypeWalk struct { |
| 55 | enums map[string][]string // frozen enum value sets, by type name |
| 56 | order []reflect.Type // named types in discovery order |
| 57 | kinds map[reflect.Type]string |
| 58 | seen map[reflect.Type]bool |
| 59 | } |
| 60 | |
| 61 | // walkSDKTypes reflection-walks the frozen registry exactly like the schema |
| 62 | // builder does — registry params and non-notification results — and adds the |
| 63 | // roots no registry DTO references by name: the host UI payload documents |
| 64 | // (carried inside json.RawMessage payload fields) and the structured error |
| 65 | // envelope ProtocolErrorData. |
| 66 | func walkSDKTypes() (*sdkTypeWalk, error) { |
| 67 | w := &sdkTypeWalk{ |
| 68 | enums: protocol.EnumValues(), |
| 69 | kinds: map[reflect.Type]string{}, |
| 70 | seen: map[reflect.Type]bool{}, |
| 71 | } |
| 72 | var roots []reflect.Type |
| 73 | for _, spec := range protocol.Registry() { |
| 74 | roots = append(roots, spec.ParamsType) |
| 75 | if !spec.Notification() { |
| 76 | roots = append(roots, spec.ResultType) |
| 77 | } |
| 78 | } |
| 79 | roots = append(roots, |
| 80 | reflect.TypeOf(protocol.UIStatusPayload{}), |
| 81 | reflect.TypeOf(protocol.UICardPayload{}), |
| 82 | reflect.TypeOf(protocol.UIFormPayload{}), |
| 83 | reflect.TypeOf(protocol.UINotificationPayload{}), |
| 84 | reflect.TypeOf(protocol.ProtocolErrorData{}), |
| 85 | ) |
| 86 | for _, root := range roots { |
| 87 | if err := w.visit(root); err != nil { |
| 88 | return nil, err |
| 89 | } |
| 90 | } |
| 91 | return w, nil |
| 92 | } |
| 93 | |
| 94 | func (w *sdkTypeWalk) visit(typ reflect.Type) error { |
| 95 | for typ.Kind() == reflect.Pointer { |
| 96 | typ = typ.Elem() |
| 97 | } |
| 98 | if typ == rawMessageType { |
| 99 | return nil |
| 100 | } |
| 101 | switch typ.Kind() { |
| 102 | case reflect.Struct: |
| 103 | if typ.Name() == "" { |
| 104 | return fmt.Errorf("anonymous struct %v is not a named wire DTO", typ) |
| 105 | } |
| 106 | if w.seen[typ] { |
| 107 | return nil |
| 108 | } |
| 109 | w.seen[typ] = true |
| 110 | w.order = append(w.order, typ) |
| 111 | w.kinds[typ] = "struct" |
| 112 | for i := 0; i < typ.NumField(); i++ { |
| 113 | field := typ.Field(i) |
| 114 | if field.PkgPath != "" { |
| 115 | continue |
| 116 | } |
| 117 | if field.Anonymous { |
| 118 | return fmt.Errorf("embedded field %v is not supported in wire DTOs", field.Type) |
| 119 | } |
| 120 | if err := w.visit(field.Type); err != nil { |
| 121 | return err |
| 122 | } |
| 123 | } |
| 124 | return nil |
| 125 | case reflect.String: |
| 126 | if typ.PkgPath() == "" { |
| 127 | return nil // predeclared string |
| 128 | } |
| 129 | if _, ok := w.enums[typ.Name()]; !ok { |
| 130 | return fmt.Errorf("named string type %s (%v) is not a frozen enum", typ.Name(), typ) |
| 131 | } |
| 132 | if w.seen[typ] { |
| 133 | return nil |
| 134 | } |
| 135 | w.seen[typ] = true |
| 136 | w.order = append(w.order, typ) |
| 137 | w.kinds[typ] = "enum" |
| 138 | return nil |
| 139 | case reflect.Slice, reflect.Array: |
| 140 | return w.visit(typ.Elem()) |
| 141 | case reflect.Map: |
| 142 | if err := w.visit(typ.Key()); err != nil { |
| 143 | return err |
| 144 | } |
| 145 | return w.visit(typ.Elem()) |
| 146 | } |
| 147 | // Predeclared scalars and unconstrained interfaces carry no named types. |
| 148 | return nil |
| 149 | } |
| 150 | |
| 151 | // generateSDKTypesGo renders the SDK's DTO mirror from the frozen walk. |
| 152 | func generateSDKTypesGo() ([]byte, error) { |
| 153 | walk, err := walkSDKTypes() |
| 154 | if err != nil { |
| 155 | return nil, err |
| 156 | } |
| 157 | var out strings.Builder |
| 158 | out.WriteString("// Code generated by cmd/extension-protocol-gen; DO NOT EDIT.\n") |
| 159 | out.WriteString("\n") |
| 160 | out.WriteString("// Package extension: Extension Protocol v1 wire DTOs, enums, method\n") |
| 161 | out.WriteString("// names, frozen limits, and the frozen error table, mirrored from the\n") |
| 162 | out.WriteString("// host's internal/extension/protocol package. Behavior (validators,\n") |
| 163 | out.WriteString("// error constructors, helpers) lives in the handwritten files.\n") |
| 164 | out.WriteString("package extension\n") |
| 165 | out.WriteString("\n") |
| 166 | out.WriteString("import \"encoding/json\"\n\n") |
| 167 | |
| 168 | emitSDKIdentity(&out) |
| 169 | emitSDKLimits(&out) |
| 170 | if err := emitSDKMethods(&out); err != nil { |
| 171 | return nil, err |
| 172 | } |
| 173 | emitSDKErrorTable(&out) |
| 174 | for _, typ := range walk.order { |
| 175 | var err error |
| 176 | switch walk.kinds[typ] { |
| 177 | case "enum": |
| 178 | err = emitSDKEnum(&out, walk, typ) |
| 179 | case "struct": |
| 180 | err = emitSDKStruct(&out, typ) |
| 181 | } |
| 182 | if err != nil { |
| 183 | return nil, err |
| 184 | } |
| 185 | } |
| 186 | if err := guardSDKSurfacePayloads(walk); err != nil { |
| 187 | return nil, err |
| 188 | } |
| 189 | formatted, err := format.Source([]byte(out.String())) |
| 190 | if err != nil { |
| 191 | return nil, fmt.Errorf("format sdk types source: %w", err) |
| 192 | } |
| 193 | return formatted, nil |
| 194 | } |
| 195 | |
| 196 | func emitSDKIdentity(out *strings.Builder) { |
| 197 | out.WriteString("// ProtocolID is the immutable identity string peers exchange during the\n") |
| 198 | out.WriteString("// initialize handshake.\n") |
| 199 | fmt.Fprintf(out, "const ProtocolID = %q\n\n", protocol.ProtocolID) |
| 200 | out.WriteString("// ProtocolMajor is the frozen major version of this protocol build.\n") |
| 201 | fmt.Fprintf(out, "const ProtocolMajor = %d\n\n", protocol.ProtocolMajor) |
| 202 | out.WriteString("// ProtocolVersion is the wire string form of ProtocolMajor carried in the\n") |
| 203 | out.WriteString("// initialize handshake.\n") |
| 204 | fmt.Fprintf(out, "const ProtocolVersion = %q\n\n", protocol.ProtocolVersion) |
| 205 | } |
| 206 | |
| 207 | func emitSDKLimits(out *strings.Builder) { |
| 208 | limits := protocol.FrozenLimits() |
| 209 | out.WriteString("// Frozen wire limits. These constants are part of the protocol contract.\n") |
| 210 | out.WriteString("const (\n") |
| 211 | out.WriteString("\t// FrameBytes caps one JSON-RPC frame on the extension transport.\n") |
| 212 | fmt.Fprintf(out, "\tFrameBytes = %d\n", limits.FrameBytes) |
| 213 | out.WriteString("\t// ExternalizeFieldBytes is the threshold above which an externalizable\n") |
| 214 | out.WriteString("\t// payload must move into a content ref instead of traveling inline.\n") |
| 215 | fmt.Fprintf(out, "\tExternalizeFieldBytes = %d\n", limits.ExternalizeFieldBytes) |
| 216 | out.WriteString("\t// ContentRefChunkBytes caps one host/content/read chunk.\n") |
| 217 | fmt.Fprintf(out, "\tContentRefChunkBytes = %d\n", limits.ContentRefChunkBytes) |
| 218 | out.WriteString("\t// ContentRefObjectBytes caps one externalized object.\n") |
| 219 | fmt.Fprintf(out, "\tContentRefObjectBytes = %d\n", limits.ContentRefObjectBytes) |
| 220 | out.WriteString(")\n\n") |
| 221 | } |
| 222 | |
| 223 | func emitSDKMethods(out *strings.Builder) error { |
| 224 | out.WriteString("// Method names, frozen for Extension Protocol v1.\n") |
| 225 | out.WriteString("const (\n") |
| 226 | seen := map[string]bool{} |
| 227 | for _, spec := range protocol.Registry() { |
| 228 | name := sdkMethodConstantName(string(spec.Name)) |
| 229 | if seen[name] { |
| 230 | return fmt.Errorf("method constant name collision: %s", name) |
| 231 | } |
| 232 | seen[name] = true |
| 233 | fmt.Fprintf(out, "\t%s = %q\n", name, string(spec.Name)) |
| 234 | } |
| 235 | out.WriteString(")\n\n") |
| 236 | return nil |
| 237 | } |
| 238 | |
| 239 | func emitSDKErrorTable(out *strings.Builder) { |
| 240 | out.WriteString("// DomainErrorCode is the JSON-RPC code every extension domain error uses on\n") |
| 241 | out.WriteString("// the wire. The structured ProtocolErrorData reason distinguishes them.\n") |
| 242 | fmt.Fprintf(out, "const DomainErrorCode = %d\n\n", protocol.DomainErrorCode) |
| 243 | out.WriteString("// errorSpec is one frozen error table entry: the JSON-RPC code, the\n") |
| 244 | out.WriteString("// generic wire message, and whether the call may be retried.\n") |
| 245 | out.WriteString("type errorSpec struct {\n\tCode int\n\tMessage string\n\tRetryable bool\n}\n\n") |
| 246 | out.WriteString("// frozenErrorSpecs mirrors the host's frozen error table. Adding an entry\n") |
| 247 | out.WriteString("// is a conscious protocol change.\n") |
| 248 | out.WriteString("var frozenErrorSpecs = map[ErrorReason]errorSpec{\n") |
| 249 | for _, contract := range protocol.ErrorContracts() { |
| 250 | fmt.Fprintf(out, "\t%s: {%s, %q, %t},\n", |
| 251 | sdkEnumConstantName("ErrorReason", string(contract.Reason)), |
| 252 | sdkErrorCodeName(contract.JSONRPCCode), contract.Message, contract.Retryable) |
| 253 | } |
| 254 | out.WriteString("}\n\n") |
| 255 | } |
| 256 | |
| 257 | // sdkErrorCodeName renders a frozen JSON-RPC code with the SDK's symbolic |
| 258 | // constant where one exists (the standard codes live in wire.go). |
| 259 | func sdkErrorCodeName(code int) string { |
| 260 | switch code { |
| 261 | case -32600: |
| 262 | return "CodeInvalidRequest" |
| 263 | case -32601: |
| 264 | return "CodeMethodNotFound" |
| 265 | case -32602: |
| 266 | return "CodeInvalidParams" |
| 267 | case -32603: |
| 268 | return "CodeInternal" |
| 269 | case protocol.DomainErrorCode: |
| 270 | return "DomainErrorCode" |
| 271 | default: |
| 272 | return strconv.Itoa(code) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func emitSDKEnum(out *strings.Builder, walk *sdkTypeWalk, typ reflect.Type) error { |
| 277 | name := typ.Name() |
| 278 | if _, ok := sdkEnumPrefixes[name]; !ok { |
| 279 | return fmt.Errorf("enum %s has no constant prefix registered", name) |
| 280 | } |
| 281 | fmt.Fprintf(out, "// %s is a generated Extension Protocol v1 string enum.\n", name) |
| 282 | fmt.Fprintf(out, "type %s string\n\n", name) |
| 283 | out.WriteString("const (\n") |
| 284 | seen := map[string]bool{} |
| 285 | for _, value := range walk.enums[name] { |
| 286 | constant := sdkEnumConstantName(name, value) |
| 287 | if seen[constant] { |
| 288 | return fmt.Errorf("enum constant name collision: %s", constant) |
| 289 | } |
| 290 | seen[constant] = true |
| 291 | fmt.Fprintf(out, "\t%s %s = %q\n", constant, name, value) |
| 292 | } |
| 293 | out.WriteString(")\n\n") |
| 294 | return nil |
| 295 | } |
| 296 | |
| 297 | func emitSDKStruct(out *strings.Builder, typ reflect.Type) error { |
| 298 | fmt.Fprintf(out, "// %s is a generated Extension Protocol v1 wire DTO.\n", typ.Name()) |
| 299 | fields := 0 |
| 300 | var body strings.Builder |
| 301 | for i := 0; i < typ.NumField(); i++ { |
| 302 | field := typ.Field(i) |
| 303 | if field.PkgPath != "" { |
| 304 | continue |
| 305 | } |
| 306 | rendered, err := renderSDKType(field.Type) |
| 307 | if err != nil { |
| 308 | return fmt.Errorf("%s field %s: %w", typ.Name(), field.Name, err) |
| 309 | } |
| 310 | fmt.Fprintf(&body, "\t%s %s `%s`\n", field.Name, rendered, string(field.Tag)) |
| 311 | fields++ |
| 312 | } |
| 313 | if fields == 0 { |
| 314 | fmt.Fprintf(out, "type %s struct{}\n\n", typ.Name()) |
| 315 | return nil |
| 316 | } |
| 317 | fmt.Fprintf(out, "type %s struct {\n%s}\n\n", typ.Name(), body.String()) |
| 318 | return nil |
| 319 | } |
| 320 | |
| 321 | // renderSDKType renders a field type as Go source. Named types resolve to |
| 322 | // their bare name — every named type reachable from the walk is either one of |
| 323 | // the mirrored DTOs/enums or encoding/json's RawMessage. |
| 324 | func renderSDKType(typ reflect.Type) (string, error) { |
| 325 | if typ == rawMessageType { |
| 326 | return "json.RawMessage", nil |
| 327 | } |
| 328 | switch typ.Kind() { |
| 329 | case reflect.Pointer: |
| 330 | elem, err := renderSDKType(typ.Elem()) |
| 331 | return "*" + elem, err |
| 332 | case reflect.Slice: |
| 333 | elem, err := renderSDKType(typ.Elem()) |
| 334 | return "[]" + elem, err |
| 335 | case reflect.Map: |
| 336 | key, err := renderSDKType(typ.Key()) |
| 337 | if err != nil { |
| 338 | return "", err |
| 339 | } |
| 340 | elem, err := renderSDKType(typ.Elem()) |
| 341 | return "map[" + key + "]" + elem, err |
| 342 | case reflect.Interface: |
| 343 | if typ.NumMethod() == 0 { |
| 344 | return "any", nil |
| 345 | } |
| 346 | case reflect.Struct: |
| 347 | if typ.Name() != "" { |
| 348 | return typ.Name(), nil |
| 349 | } |
| 350 | case reflect.String: |
| 351 | if typ.Name() != "" { |
| 352 | return typ.Name(), nil |
| 353 | } |
| 354 | return "string", nil |
| 355 | case reflect.Bool: |
| 356 | return "bool", nil |
| 357 | case reflect.Int: |
| 358 | return "int", nil |
| 359 | case reflect.Int64: |
| 360 | return "int64", nil |
| 361 | case reflect.Uint64: |
| 362 | return "uint64", nil |
| 363 | case reflect.Float64: |
| 364 | return "float64", nil |
| 365 | } |
| 366 | return "", fmt.Errorf("unsupported wire type %v", typ) |
| 367 | } |
| 368 | |
| 369 | // guardSDKSurfacePayloads pins the surface-kind → payload-DTO convention: a |
| 370 | // new UISurfaceKind without a matching UI<Kind>Payload type in the generated |
| 371 | // set fails generation loudly instead of silently shipping an SDK that |
| 372 | // cannot build the new surface. |
| 373 | func guardSDKSurfacePayloads(walk *sdkTypeWalk) error { |
| 374 | emitted := map[string]bool{} |
| 375 | for _, typ := range walk.order { |
| 376 | emitted[typ.Name()] = true |
| 377 | } |
| 378 | for _, kind := range walk.enums["UISurfaceKind"] { |
| 379 | want := "UI" + sdkIdentifierFromValue(kind) + "Payload" |
| 380 | if !emitted[want] { |
| 381 | return fmt.Errorf("surface kind %q has no payload DTO %s in the generated set", kind, want) |
| 382 | } |
| 383 | } |
| 384 | return nil |
| 385 | } |
| 386 | |
| 387 | // sdkMethodConstantName mangles a wire method name into its Go constant |
| 388 | // name: "extension/ui/action" → MethodExtensionUIAction. |
| 389 | func sdkMethodConstantName(method string) string { |
| 390 | return "Method" + sdkIdentifierFromValue(method) |
| 391 | } |
| 392 | |
| 393 | // sdkEnumConstantName mangles an enum value into its Go constant name with |
| 394 | // the type's registered prefix; exceptions pin the established public API. |
| 395 | func sdkEnumConstantName(typeName, value string) string { |
| 396 | if exceptions, ok := sdkEnumConstantExceptions[typeName]; ok { |
| 397 | if name, ok := exceptions[value]; ok { |
| 398 | return name |
| 399 | } |
| 400 | } |
| 401 | return sdkEnumPrefixes[typeName] + sdkIdentifierFromValue(value) |
| 402 | } |
| 403 | |
| 404 | // sdkIdentifierFromValue turns a wire value into Go identifier parts: |
| 405 | // "agent.before_start" → "AgentBeforeStart". |
| 406 | func sdkIdentifierFromValue(value string) string { |
| 407 | parts := strings.FieldsFunc(value, func(r rune) bool { |
| 408 | return r == '_' || r == '.' || r == '-' || r == '/' |
| 409 | }) |
| 410 | var out strings.Builder |
| 411 | for _, part := range parts { |
| 412 | if initialism, ok := sdkInitialisms[part]; ok { |
| 413 | out.WriteString(initialism) |
| 414 | continue |
| 415 | } |
| 416 | out.WriteString(strings.ToUpper(part[:1]) + part[1:]) |
| 417 | } |
| 418 | return out.String() |
| 419 | } |
| 420 |