// Copyright (c) 2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. package agent_test import ( "context" "encoding/json" "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/agent" ) func TestFunctionTool_Name(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "my_tool", "does things", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, nil }, ) assert.Equal(t, "my_tool", tool.Name()) } func TestFunctionTool_Definition(t *testing.T) { t.Parallel() t.Run( "returns name and description", func(t *testing.T) { t.Parallel() type Params struct { Query string `json:"query"` } tool := agent.FunctionTool( "search", "Search for items", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, nil }, ) def := tool.Definition() assert.Equal(t, "search", def.Name) assert.Equal(t, "Search for items", def.Description) }, ) t.Run( "generates valid JSON schema from params type", func(t *testing.T) { t.Parallel() type Params struct { Name string `json:"name" jsonschema:"The item name"` Count int `json:"count"` } tool := agent.FunctionTool( "create", "Create items", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, nil }, ) def := tool.Definition() require.NotNil(t, def.Parameters) var schema map[string]any require.NoError(t, json.Unmarshal(def.Parameters, &schema)) assert.Equal(t, "object", schema["type"]) props := schema["properties"].(map[string]any) assert.Contains(t, props, "name") assert.Contains(t, props, "count") nameProp := props["name"].(map[string]any) assert.Equal(t, "string", nameProp["type"]) assert.Equal(t, "The item name", nameProp["description"]) countProp := props["count"].(map[string]any) assert.Equal(t, "integer", countProp["type"]) }, ) t.Run( "empty struct produces object schema with no properties", func(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "noop", "No-op", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, nil }, ) var schema map[string]any require.NoError(t, json.Unmarshal(tool.Definition().Parameters, &schema)) assert.Equal(t, "object", schema["type"]) }, ) t.Run( "pointer fields are not nullable in schema", func(t *testing.T) { t.Parallel() type Params struct { Title *string `json:"title,omitempty"` } tool := agent.FunctionTool( "update", "Update", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, nil }, ) var schema map[string]any require.NoError(t, json.Unmarshal(tool.Definition().Parameters, &schema)) props := schema["properties"].(map[string]any) titleProp := props["title"].(map[string]any) assert.Equal(t, "string", titleProp["type"]) assert.Nil(t, titleProp["types"]) }, ) } func TestFunctionTool_Execute(t *testing.T) { t.Parallel() t.Run( "unmarshals params and calls function", func(t *testing.T) { t.Parallel() type Params struct { X int `json:"x"` Y int `json:"y"` } tool := agent.FunctionTool( "add", "Add two numbers", func(_ context.Context, p Params) (agent.ToolResult, error) { return agent.ToolResult{Content: "42"}, nil }, ) result, err := tool.Execute(context.Background(), `{"x": 1, "y": 2}`) require.NoError(t, err) assert.Equal(t, "42", result.Content) assert.False(t, result.IsError) }, ) t.Run( "passes received params to function", func(t *testing.T) { t.Parallel() type Params struct { City string `json:"city"` } var received string tool := agent.FunctionTool( "weather", "Get weather", func(_ context.Context, p Params) (agent.ToolResult, error) { received = p.City return agent.ToolResult{Content: "sunny"}, nil }, ) _, err := tool.Execute(context.Background(), `{"city":"Paris"}`) require.NoError(t, err) assert.Equal(t, "Paris", received) }, ) t.Run( "invalid JSON returns tool error not Go error", func(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "noop", "No-op", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{Content: "ok"}, nil }, ) result, err := tool.Execute(context.Background(), `{invalid`) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, result.Content, "Invalid parameters") }, ) t.Run( "infrastructure error propagated as Go error", func(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "fail", "Always fails", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, errors.New("db down") }, ) _, err := tool.Execute(context.Background(), `{}`) require.Error(t, err) assert.Contains(t, err.Error(), "db down") }, ) t.Run( "context is forwarded to function", func(t *testing.T) { t.Parallel() type ctxKey struct{} type Params struct{} tool := agent.FunctionTool( "ctx_check", "Check context", func(ctx context.Context, _ Params) (agent.ToolResult, error) { val := ctx.Value(ctxKey{}).(string) return agent.ToolResult{Content: val}, nil }, ) ctx := context.WithValue(context.Background(), ctxKey{}, "hello") result, err := tool.Execute(ctx, `{}`) require.NoError(t, err) assert.Equal(t, "hello", result.Content) }, ) t.Run( "missing single required field returns tool error", func(t *testing.T) { t.Parallel() type Params struct { City string `json:"city"` } tool := agent.FunctionTool( "weather", "Get weather", func(_ context.Context, _ Params) (agent.ToolResult, error) { t.Fatal("function should not be called") return agent.ToolResult{}, nil }, ) result, err := tool.Execute(context.Background(), `{}`) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, result.Content, "city") }, ) t.Run( "missing multiple required fields lists all of them", func(t *testing.T) { t.Parallel() type Params struct { City string `json:"city"` Country string `json:"country"` } tool := agent.FunctionTool( "weather", "Get weather", func(_ context.Context, _ Params) (agent.ToolResult, error) { t.Fatal("function should not be called") return agent.ToolResult{}, nil }, ) result, err := tool.Execute(context.Background(), `{}`) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, result.Content, "city") assert.Contains(t, result.Content, "country") }, ) t.Run( "partially missing required fields detected", func(t *testing.T) { t.Parallel() type Params struct { City string `json:"city"` Country string `json:"country"` } tool := agent.FunctionTool( "weather", "Get weather", func(_ context.Context, _ Params) (agent.ToolResult, error) { t.Fatal("function should not be called") return agent.ToolResult{}, nil }, ) result, err := tool.Execute(context.Background(), `{"city":"Paris"}`) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, result.Content, "country") }, ) t.Run( "optional fields can be omitted", func(t *testing.T) { t.Parallel() type Params struct { City string `json:"city"` Units *string `json:"units,omitempty"` } tool := agent.FunctionTool( "weather", "Get weather", func(_ context.Context, p Params) (agent.ToolResult, error) { return agent.ToolResult{Content: "sunny in " + p.City}, nil }, ) result, err := tool.Execute(context.Background(), `{"city":"Paris"}`) require.NoError(t, err) assert.False(t, result.IsError) assert.Equal(t, "sunny in Paris", result.Content) }, ) t.Run( "extra JSON fields are ignored", func(t *testing.T) { t.Parallel() type Params struct { Name string `json:"name"` } tool := agent.FunctionTool( "greet", "Greet", func(_ context.Context, p Params) (agent.ToolResult, error) { return agent.ToolResult{Content: "hi " + p.Name}, nil }, ) result, err := tool.Execute(context.Background(), `{"name":"Alice","extra":"ignored"}`) require.NoError(t, err) assert.Equal(t, "hi Alice", result.Content) assert.False(t, result.IsError) }, ) t.Run( "empty JSON object works for empty params", func(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "ping", "Ping", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{Content: "pong"}, nil }, ) result, err := tool.Execute(context.Background(), `{}`) require.NoError(t, err) assert.Equal(t, "pong", result.Content) }, ) t.Run( "function can return IsError true", func(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "validate", "Validate input", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{Content: "validation failed", IsError: true}, nil }, ) result, err := tool.Execute(context.Background(), `{}`) require.NoError(t, err) assert.True(t, result.IsError) assert.Equal(t, "validation failed", result.Content) }, ) } func TestFunctionTool_InterfaceSatisfaction(t *testing.T) { t.Parallel() type Params struct{} tool := agent.FunctionTool( "test", "test tool", func(_ context.Context, _ Params) (agent.ToolResult, error) { return agent.ToolResult{}, nil }, ) assert.Implements(t, (*agent.Tool)(nil), tool) assert.Implements(t, (*agent.ToolDescriptor)(nil), tool) }