diff --git a/internal/tool/api_compat.go b/internal/tool/api_compat.go index 0d2fe912..c6385a7e 100644 --- a/internal/tool/api_compat.go +++ b/internal/tool/api_compat.go @@ -630,6 +630,13 @@ type APICompatTool struct { checker *CompatChecker } +// APICompatInput is the typed input for APICompatTool. +type APICompatInput struct { + PackagePath string `json:"package_path"` + BaselinePath string `json:"baseline_path"` + SaveBaseline bool `json:"save_baseline"` +} + // NewAPICompatTool creates a new APICompatTool. func NewAPICompatTool() *APICompatTool { return &APICompatTool{ @@ -646,34 +653,30 @@ func (t *APICompatTool) Description() string { } func (t *APICompatTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "package_path": map[string]interface{}{ - "type": "string", - "description": "Path to the Go package directory to analyze", - }, - "baseline_path": map[string]interface{}{ - "type": "string", - "description": "Path to the baseline snapshot JSON file", - }, - "save_baseline": map[string]interface{}{ - "type": "boolean", - "description": "If true, save current API as baseline instead of comparing", - }, + return apiCompatSchema.ToJSONSchema() +} + +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (t APICompatTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "package_path": {Type: "string", Description: "Path to the Go package directory to analyze"}, + "baseline_path": {Type: "string", Description: "Path to the baseline snapshot JSON file"}, + "save_baseline": {Type: "boolean", Description: "If true, save current API as baseline instead of comparing"}, }, - "required": []string{"package_path"}, + Required: []string{"package_path"}, } } +// apiCompatSchema is the single source of truth for APICompat's input schema. +var apiCompatSchema = APICompatTool{}.Schema() + func (t *APICompatTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - PackagePath string `json:"package_path"` - BaselinePath string `json:"baseline_path"` - SaveBaseline bool `json:"save_baseline"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("parsing parameters: %w", err) + params, err := DecodeInput[APICompatInput]("APICompat", input) + if err != nil { + return "", err } if params.PackagePath == "" { diff --git a/internal/tool/app_verify.go b/internal/tool/app_verify.go index e4068ba0..ad024061 100644 --- a/internal/tool/app_verify.go +++ b/internal/tool/app_verify.go @@ -19,6 +19,13 @@ import ( // by covering the part that actually proves the app runs. type AppVerifyTool struct{} +// AppVerifyInput is the typed input for AppVerifyTool. +type AppVerifyInput struct { + Action string `json:"action"` + Path string `json:"path"` + ReadinessSeconds int `json:"readiness_seconds"` +} + func (AppVerifyTool) Name() string { return "AppVerify" } func (AppVerifyTool) RiskLevel() string { return "medium" } func (AppVerifyTool) Aliases() []string { return []string{"app-verify", "verify_app"} } @@ -26,30 +33,27 @@ func (AppVerifyTool) Description() string { return "Detect how this project boots and prove it runs: infer an install/build/test/start recipe, persist it to .rho/verify/environment.json as the verification contract, and run a bounded boot smoke check with readiness polling. Use action=detect to inspect, action=manifest to write/update the contract, action=smoke to boot the app and verify readiness." } -func (AppVerifyTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"detect", "manifest", "smoke"}, - "description": "detect infers the recipe; manifest loads-or-detects and persists .rho/verify/environment.json; smoke boots the app using the recipe's start command and polls readiness.", - }, - "path": map[string]interface{}{ - "type": "string", - "description": "Project directory (default: session working directory).", - }, - "readiness_seconds": map[string]interface{}{ - "type": "integer", - "minimum": 1, - "maximum": 300, - "description": "Max seconds to wait for the app to become ready during smoke (default 60).", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (AppVerifyTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"detect", "manifest", "smoke"}, Description: "detect infers the recipe; manifest loads-or-detects and persists .rho/verify/environment.json; smoke boots the app using the recipe's start command and polls readiness."}, + "path": {Type: "string", Description: "Project directory (default: session working directory)."}, + "readiness_seconds": {Type: "integer", Minimum: 1, Maximum: 300, Description: "Max seconds to wait for the app to become ready during smoke (default 60)."}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } +func (AppVerifyTool) Parameters() map[string]interface{} { + return appVerifySchema.ToJSONSchema() +} + +// appVerifySchema is the single source of truth for AppVerify's input schema. +var appVerifySchema = AppVerifyTool{}.Schema() + type smokeResult struct { Status string `json:"status"` // passed | failed | skipped StartCmd string `json:"start_command,omitempty"` @@ -60,13 +64,9 @@ type smokeResult struct { } func (AppVerifyTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - Action string `json:"action"` - Path string `json:"path"` - ReadinessSeconds int `json:"readiness_seconds"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[AppVerifyInput]("AppVerify", input) + if err != nil { + return "", err } switch params.Action { case "detect", "manifest", "smoke": diff --git a/internal/tool/auto_import.go b/internal/tool/auto_import.go index 8053b21a..afd212a1 100644 --- a/internal/tool/auto_import.go +++ b/internal/tool/auto_import.go @@ -882,6 +882,13 @@ type AutoImportTool struct { importer *AutoImporter } +// AutoImportInput is the typed input for AutoImportTool. +type AutoImportInput struct { + Code string `json:"code"` + File string `json:"file"` + Apply bool `json:"apply"` +} + // NewAutoImportTool creates an AutoImportTool with a pre-configured AutoImporter. func NewAutoImportTool() *AutoImportTool { return &AutoImportTool{ @@ -894,35 +901,31 @@ func (AutoImportTool) Description() string { return "Automatically detect and add missing imports in Go source code. Analyzes package-qualified symbol references and adds the corresponding import statements." } -func (AutoImportTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "code": map[string]interface{}{ - "type": "string", - "description": "Go source code to analyze for missing imports", - }, - "file": map[string]interface{}{ - "type": "string", - "description": "Optional file path for context (used in fix reporting)", - }, - "apply": map[string]interface{}{ - "type": "boolean", - "description": "If true, return the code with fixes applied; if false, only report missing imports", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (AutoImportTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "code": {Type: "string", Description: "Go source code to analyze for missing imports"}, + "file": {Type: "string", Description: "Optional file path for context (used in fix reporting)"}, + "apply": {Type: "boolean", Description: "If true, return the code with fixes applied; if false, only report missing imports"}, }, - "required": []string{"code"}, + Required: []string{"code"}, } } +func (AutoImportTool) Parameters() map[string]interface{} { + return autoImportSchema.ToJSONSchema() +} + +// autoImportSchema is the single source of truth for AutoImport's input schema. +var autoImportSchema = AutoImportTool{}.Schema() + func (t AutoImportTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Code string `json:"code"` - File string `json:"file"` - Apply bool `json:"apply"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[AutoImportInput]("AutoImport", input) + if err != nil { + return "", err } if p.Code == "" { return "", fmt.Errorf("code is required") diff --git a/internal/tool/conflict_resolver.go b/internal/tool/conflict_resolver.go index 6de7371d..d1520ff6 100644 --- a/internal/tool/conflict_resolver.go +++ b/internal/tool/conflict_resolver.go @@ -518,42 +518,44 @@ func describeResolution(c *Conflict) string { // ConflictResolverTool resolves git merge conflicts in files. type ConflictResolverTool struct{} +// ConflictResolverInput is the typed input for ConflictResolverTool. +type ConflictResolverInput struct { + Path string `json:"path"` + Strategy string `json:"strategy"` + DryRun bool `json:"dry_run"` +} + func (ConflictResolverTool) Name() string { return "ResolveConflicts" } func (ConflictResolverTool) Description() string { return "Automatically resolve git merge conflicts in files. Supports smart resolution strategies including import merging, additive merging, and trivial conflict resolution." } -func (ConflictResolverTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "Absolute path to the file with merge conflicts", - }, - "strategy": map[string]interface{}{ - "type": "string", - "description": "Resolution strategy: 'smart' (default), 'ours', or 'theirs'", - "enum": []string{"smart", "ours", "theirs"}, - }, - "dry_run": map[string]interface{}{ - "type": "boolean", - "description": "If true, show resolution plan without modifying the file", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ConflictResolverTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "path": {Type: "string", Description: "Absolute path to the file with merge conflicts"}, + "strategy": {Type: "string", Enum: []interface{}{"smart", "ours", "theirs"}, Description: "Resolution strategy: 'smart' (default), 'ours', or 'theirs'"}, + "dry_run": {Type: "boolean", Description: "If true, show resolution plan without modifying the file"}, }, - "required": []string{"path"}, + Required: []string{"path"}, } } +func (ConflictResolverTool) Parameters() map[string]interface{} { + return conflictResolverSchema.ToJSONSchema() +} + +// conflictResolverSchema is the single source of truth for ConflictResolver's input schema. +var conflictResolverSchema = ConflictResolverTool{}.Schema() + func (ConflictResolverTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Path string `json:"path"` - Strategy string `json:"strategy"` - DryRun bool `json:"dry_run"` - } - if err := json.Unmarshal(input, &p); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + p, err := DecodeInput[ConflictResolverInput]("ConflictResolver", input) + if err != nil { + return "", err } if p.Path == "" { return "", fmt.Errorf("path is required") diff --git a/internal/tool/cron.go b/internal/tool/cron.go index 4d450a66..0ac521fe 100644 --- a/internal/tool/cron.go +++ b/internal/tool/cron.go @@ -353,13 +353,20 @@ type CronListTool struct{} func (CronListTool) Name() string { return "CronList" } func (CronListTool) Aliases() []string { return []string{"cron_list"} } func (CronListTool) Description() string { return "List all scheduled cron jobs" } + +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (CronListTool) Schema() ToolSchema { + return ToolSchema{Type: "object", Properties: map[string]SchemaProperty{}} +} + func (CronListTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - } + return cronListSchema.ToJSONSchema() } +// cronListSchema is the single source of truth for CronList's input schema. +var cronListSchema = CronListTool{}.Schema() + func (CronListTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { jobs := globalCronScheduler.List() items := make([]map[string]any, 0, len(jobs)) diff --git a/internal/tool/extra.go b/internal/tool/extra.go index d0bc020d..8fda842f 100644 --- a/internal/tool/extra.go +++ b/internal/tool/extra.go @@ -165,42 +165,43 @@ func (ConfigTool) Execute(ctx context.Context, input json.RawMessage) (string, e // Text outside this tool is visible in the detail view; the answer lives here. type BriefTool struct{} +// BriefInput is the typed input for BriefTool. +type BriefInput struct { + Message string `json:"message"` + Attachments []string `json:"attachments"` + Status string `json:"status"` +} + func (BriefTool) Name() string { return "SendUserMessage" } func (BriefTool) Aliases() []string { return []string{"brief", "Brief"} } func (BriefTool) Description() string { return "Send a message to the user. Supports markdown. Use 'proactive' status when surfacing something the user hasn't asked for." } -func (BriefTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "message": map[string]interface{}{ - "type": "string", - "description": "The message for the user. Supports markdown formatting.", - }, - "attachments": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - "description": "Optional file paths to attach (images, diffs, logs)", - }, - "status": map[string]interface{}{ - "type": "string", - "enum": []string{"normal", "proactive"}, - "description": "Use 'proactive' when surfacing something the user hasn't asked for", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (BriefTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "message": {Type: "string", Description: "The message for the user. Supports markdown formatting."}, + "attachments": {Type: "array", Items: &SchemaProperty{Type: "string"}, Description: "Optional file paths to attach (images, diffs, logs)"}, + "status": {Type: "string", Enum: []interface{}{"normal", "proactive"}, Description: "Use 'proactive' when surfacing something the user hasn't asked for"}, }, - "required": []string{"message"}, + Required: []string{"message"}, } } +func (BriefTool) Parameters() map[string]interface{} { + return briefSchema.ToJSONSchema() +} + +// briefSchema is the single source of truth for Brief's input schema. +var briefSchema = BriefTool{}.Schema() + func (BriefTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - Message string `json:"message"` - Attachments []string `json:"attachments"` - Status string `json:"status"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[BriefInput]("SendUserMessage", input) + if err != nil { return "", err } if p.Message == "" { diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index 6b288191..ffcae7e4 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -372,3 +372,109 @@ func TestOutlineSchemaProvider(t *testing.T) { t.Fatalf("Outline has no required fields, got %v", OutlineTool{}.Parameters()["required"]) } } + +func TestCronListSchemaProvider(t *testing.T) { + var _ SchemaProvider = CronListTool{} + props := schemaProps(t, CronListTool{}.Parameters()) + if len(props) != 0 { + t.Fatalf("CronList has no inputs, got %v", props) + } + _, hasRequired := CronListTool{}.Parameters()["required"] + if hasRequired { + t.Fatalf("CronList has no required fields, got %v", CronListTool{}.Parameters()["required"]) + } +} + +func TestBriefSchemaProvider(t *testing.T) { + var _ SchemaProvider = BriefTool{} + props := schemaProps(t, BriefTool{}.Parameters()) + for field, want := range map[string]string{"message": "string", "status": "string"} { + if props[field].(map[string]interface{})["type"] != want { + t.Fatalf("%s type = %v, want %s", field, props[field], want) + } + } + att, ok := props["attachments"].(map[string]interface{}) + if !ok || att["type"] != "array" { + t.Fatalf("attachments prop = %v, want array", props["attachments"]) + } + if att["items"].(map[string]interface{})["type"] != "string" { + t.Fatalf("attachments items = %v, want string", att["items"]) + } + enum, ok := props["status"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 2 || enum[0] != "normal" || enum[1] != "proactive" { + t.Fatalf("status enum = %v, want [normal proactive]", props["status"]) + } + req, _ := BriefTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "message" { + t.Fatalf("required = %v, want [message]", BriefTool{}.Parameters()["required"]) + } +} + +func TestAPICompatSchemaProvider(t *testing.T) { + compat := &APICompatTool{} + var _ SchemaProvider = compat + props := schemaProps(t, compat.Parameters()) + for field, want := range map[string]string{"package_path": "string", "baseline_path": "string", "save_baseline": "boolean"} { + if props[field].(map[string]interface{})["type"] != want { + t.Fatalf("%s type = %v, want %s", field, props[field], want) + } + } + req, _ := compat.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "package_path" { + t.Fatalf("required = %v, want [package_path]", compat.Parameters()["required"]) + } +} + +func TestAutoImportSchemaProvider(t *testing.T) { + var _ SchemaProvider = AutoImportTool{} + props := schemaProps(t, AutoImportTool{}.Parameters()) + for field, want := range map[string]string{"code": "string", "file": "string", "apply": "boolean"} { + if props[field].(map[string]interface{})["type"] != want { + t.Fatalf("%s type = %v, want %s", field, props[field], want) + } + } + req, _ := AutoImportTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "code" { + t.Fatalf("required = %v, want [code]", AutoImportTool{}.Parameters()["required"]) + } +} + +func TestConflictResolverSchemaProvider(t *testing.T) { + var _ SchemaProvider = ConflictResolverTool{} + props := schemaProps(t, ConflictResolverTool{}.Parameters()) + if props["path"].(map[string]interface{})["type"] != "string" { + t.Fatal("path type wrong") + } + enum, ok := props["strategy"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "smart" || enum[2] != "theirs" { + t.Fatalf("strategy enum = %v, want [smart ours theirs]", props["strategy"]) + } + if props["dry_run"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("dry_run type wrong") + } + req, _ := ConflictResolverTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "path" { + t.Fatalf("required = %v, want [path]", ConflictResolverTool{}.Parameters()["required"]) + } +} + +func TestAppVerifySchemaProvider(t *testing.T) { + var _ SchemaProvider = AppVerifyTool{} + props := schemaProps(t, AppVerifyTool{}.Parameters()) + action, ok := props["action"].(map[string]interface{}) + if !ok || action["type"] != "string" { + t.Fatalf("action prop = %v, want string", props["action"]) + } + enum, ok := action["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "detect" || enum[2] != "smoke" { + t.Fatalf("action enum = %v, want [detect manifest smoke]", action["enum"]) + } + rs := props["readiness_seconds"].(map[string]interface{}) + if rs["minimum"] != 1 || rs["maximum"] != 300 { + t.Fatalf("readiness_seconds bounds = %v/%v, want 1/300", rs["minimum"], rs["maximum"]) + } + req, _ := AppVerifyTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", AppVerifyTool{}.Parameters()["required"]) + } +}