Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions agent_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package agentic

import (
"context"
"errors"
"strings"
"testing"

"github.com/regularkevvv/agentic-go/internal/testutil"
testprovider "github.com/regularkevvv/agentic-go/provider/test"
)

func TestNewAgentDynamicRegistersHandoffs(t *testing.T) {
child := NewAgent[handoffDeps]("child", testprovider.NewTestModel(testprovider.ModelResponse{Text: "ok"}))
h := NewHandoff("delegate", "delegate work", child)

agent := NewAgentDynamic[handoffDeps](
func(ctx RunContext[handoffDeps]) (string, error) { return "dynamic", nil },
&testutil.StubModel{NameValue: "dynamic-model"},
WithHandoffs(h),
)

if agent.registry == nil || !agent.registry.Has("delegate") {
t.Fatalf("expected dynamic agent to register handoff, got %#v", agent.registry)
}
}

func TestAgentRunAdditionalErrorPaths(t *testing.T) {
t.Run("no choices in response", func(t *testing.T) {
agent := NewAgent[any]("system", &testutil.StubModel{
NameValue: "empty-model",
Response: &ChatResponse{},
})

_, err := agent.Run(context.Background(), "prompt", nil)
if err == nil || err.Error() != "no choices in response" {
t.Fatalf("expected no choices error, got %v", err)
}
})

t.Run("tool call without registered tools", func(t *testing.T) {
agent := NewAgent[any]("system", &testutil.StubModel{
NameValue: "tool-model",
Response: &ChatResponse{
Choices: []Choice{{
Message: NewToolUseMessage(ToolUse{
ID: "call_1",
Name: "lookup",
Input: map[string]interface{}{"city": "Lima"},
}),
FinishReason: FinishReasonToolCalls,
}},
},
})

_, err := agent.Run(context.Background(), "prompt", nil)
if err == nil || !strings.Contains(err.Error(), "no tools are registered") {
t.Fatalf("expected missing registry error, got %v", err)
}
})

t.Run("model error is wrapped", func(t *testing.T) {
agent := NewAgent[any]("system", &testutil.StubModel{
NameValue: "error-model",
Err: errors.New("boom"),
})

_, err := agent.Run(context.Background(), "prompt", nil)
if err == nil || err.Error() != "model request: boom" {
t.Fatalf("expected wrapped model error, got %v", err)
}
})
}
13 changes: 13 additions & 0 deletions handoff_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,16 @@ func TestAddHandoffAndWithHandoffs(t *testing.T) {
t.Fatalf("expected handoff to be registered via option")
}
}

func TestAddHandoffPanicsOnInvalidToolDefinition(t *testing.T) {
parent := NewAgent[handoffDeps]("parent", testprovider.NewTestModel(testprovider.ModelResponse{Text: "ok"}))
child := NewAgent[handoffDeps]("child", testprovider.NewTestModel(testprovider.ModelResponse{Text: "ok"}))

defer func() {
if r := recover(); r == nil {
t.Fatal("expected AddHandoff to panic for an invalid handoff tool")
}
}()

parent.AddHandoff(NewHandoff("", "delegate work", child))
}
43 changes: 42 additions & 1 deletion mcp/mcp_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestClientConnectListToolsCallToolAndToolsetOverSSE(t *testing.T) {
sseServer := server.NewTestServer(mcpServer)
defer sseServer.Close()

client := NewSSEClient("remote-tools", sseServer.URL+"/sse")
client := NewSSEClient("remote-tools", sseServer.URL+"/sse", WithHeaders(map[string]string{"X-Test": "1"}))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Expand Down Expand Up @@ -147,3 +147,44 @@ func TestClientConnectOverHTTPAndStdioFailure(t *testing.T) {
t.Fatal("expected stdio connect to fail for a missing command")
}
}

func TestClientListToolsPaginatesOverHTTP(t *testing.T) {
mcpServer := server.NewMCPServer(
"http-server",
"1.0.0",
server.WithToolCapabilities(true),
server.WithPaginationLimit(1),
)
for _, name := range []string{"alpha", "beta", "gamma"} {
mcpServer.AddTool(
mcpgo.NewTool(name),
func(ctx context.Context, request mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
return mcpgo.NewToolResultText("ok"), nil
},
)
}

httpHandler := server.NewStreamableHTTPServer(mcpServer)
httpServer := httptest.NewServer(httpHandler)
defer httpServer.Close()

client := NewHTTPClient("remote-http", httpServer.URL+"/mcp", WithHeaders(map[string]string{"X-Test": "1"}))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := client.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}

tools, err := client.listTools(ctx)
if err != nil {
t.Fatalf("listTools: %v", err)
}
if len(tools) != 3 {
t.Fatalf("expected all paginated tools, got %#v", tools)
}

if err := client.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
18 changes: 18 additions & 0 deletions multimodal_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package agentic

import "testing"

func TestInferMediaTypeAdditionalExtensions(t *testing.T) {
tests := map[string]string{
"poster.gif": "image/gif",
"texture.webp": "image/webp",
"photo.jpg": "image/jpeg",
"photo.jpeg": "image/jpeg",
}

for path, want := range tests {
if got := inferMediaType(path); got != want {
t.Fatalf("inferMediaType(%q) = %q, want %q", path, got, want)
}
}
}
36 changes: 36 additions & 0 deletions output_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,40 @@ func TestToolOutputSpecErrors(t *testing.T) {
t.Fatalf("expected invalid JSON error, got %v", err)
}
})

t.Run("returns marshal error for non-json tool input", func(t *testing.T) {
spec := NewToolOutput[outputCoverageValue]("desc")
msg := NewToolUseMessage(ToolUse{
ID: "call_1",
Name: "__output__",
Input: map[string]interface{}{
"value": func() {},
},
})

_, err := spec.Parse(msg)
if err == nil || !strings.Contains(err.Error(), "marshal output tool input") {
t.Fatalf("expected marshal error, got %v", err)
}
})

t.Run("returns unmarshal error for wrong tool input shape", func(t *testing.T) {
type numericOutput struct {
Value int `json:"value"`
}

spec := NewToolOutput[numericOutput]("desc")
msg := NewToolUseMessage(ToolUse{
ID: "call_1",
Name: "__output__",
Input: map[string]interface{}{
"value": "wrong",
},
})

_, err := spec.Parse(msg)
if err == nil || !strings.Contains(err.Error(), "unmarshal to") {
t.Fatalf("expected unmarshal error, got %v", err)
}
})
}
54 changes: 54 additions & 0 deletions provider/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package anthropic

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/anthropics/anthropic-sdk-go"
Expand Down Expand Up @@ -300,6 +302,36 @@ func TestBuildParamsDefaults(t *testing.T) {
}
}

func TestBuildParamsThinkingAndResponseFormat(t *testing.T) {
model, _ := New("claude-sonnet-4-20250514", WithAPIKey("test-key"))
temp := 0.2

params := model.buildParams(&core.ChatRequest{
Model: "claude-sonnet-4-20250514",
Messages: []core.Message{
core.NewTextMessage(core.RoleUser, "hello"),
},
Temperature: &temp,
ResponseFormat: &core.ResponseFormat{
Type: "json_schema",
JSONSchema: &core.JSONSchemaFormat{
Schema: map[string]interface{}{"type": "object"},
},
},
Thinking: &core.ThinkingConfig{Enabled: true},
})

if params.OutputConfig.Format.Schema["type"] != "object" {
t.Fatalf("expected output schema to be preserved, got %#v", params.OutputConfig)
}
if params.Thinking.OfEnabled == nil || params.Thinking.OfEnabled.BudgetTokens != 10000 {
t.Fatalf("expected thinking budget default, got %#v", params.Thinking)
}
if got := params.Temperature.Value; got != 1 {
t.Fatalf("expected thinking to force temperature=1, got %#v", got)
}
}

func TestConvertResponseMessageEmpty(t *testing.T) {
// Test with empty content
content := []anthropic.ContentBlockUnion{}
Expand Down Expand Up @@ -388,3 +420,25 @@ func TestRequestValidationError(t *testing.T) {
t.Error("expected validation error")
}
}

func TestRequestServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer server.Close()

model, err := New("claude-sonnet-4-20250514", WithAPIKey("test-key"), WithBaseURL(server.URL))
if err != nil {
t.Fatalf("New: %v", err)
}

_, err = model.Request(context.Background(), &core.ChatRequest{
Model: "claude-sonnet-4-20250514",
Messages: []core.Message{
core.NewTextMessage(core.RoleUser, "hello"),
},
})
if err == nil {
t.Fatal("expected request error")
}
}
14 changes: 14 additions & 0 deletions provider/bedrock/bedrock_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ func TestWithProfileOption(t *testing.T) {
}
}

func TestNewWithInvalidProfileReturnsConfigError(t *testing.T) {
_, err := New("anthropic.test", WithRegion("us-east-1"), WithProfile("definitely-missing-bedrock-profile"))
if err == nil {
t.Fatal("expected config loading error for missing AWS profile")
}
}

func TestBedrockRequestValidationErrors(t *testing.T) {
model := &Model{modelID: "anthropic.test"}

Expand Down Expand Up @@ -226,6 +233,13 @@ func TestBuildParamsAndInputs(t *testing.T) {
}
}

func TestConvertOutputMessageIgnoresNonMessageOutput(t *testing.T) {
msg := convertOutputMessage(nil)
if msg.Role != core.RoleAssistant || len(msg.Content) != 0 {
t.Fatalf("expected empty assistant message for non-message output, got %#v", msg)
}
}

func TestConvertSystemBlocksAndMessage(t *testing.T) {
if got := convertSystemBlocks(core.Message{}); got != nil {
t.Fatalf("expected nil system blocks for empty message, got %#v", got)
Expand Down
12 changes: 12 additions & 0 deletions provider/ollama/ollama_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ func TestNewFromEnvHost(t *testing.T) {
}
}

func TestNewFromEnvAPIKey(t *testing.T) {
t.Setenv("OLLAMA_API_KEY", "env-secret")

model, err := New("llama3.2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if model.Name() != "llama3.2" {
t.Errorf("expected name %q, got %q", "llama3.2", model.Name())
}
}

func TestMustNew(t *testing.T) {
model := MustNew("llama3.2")
if model.Name() != "llama3.2" {
Expand Down
16 changes: 16 additions & 0 deletions provider/openai/openai_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ func TestBuildParamsAppliesOptionalFields(t *testing.T) {
}
}

func TestBuildParamsUsesMediumReasoningEffortForDefaultThinkingBudget(t *testing.T) {
model := &Model{model: "gpt-4o"}

params := model.buildParams(&core.ChatRequest{
Model: "gpt-4o",
Messages: []core.Message{
core.NewTextMessage(core.RoleUser, "hello"),
},
Thinking: &core.ThinkingConfig{Enabled: true, BudgetTokens: 10000},
})

if params.ReasoningEffort != shared.ReasoningEffortMedium {
t.Fatalf("expected medium reasoning effort, got %q", params.ReasoningEffort)
}
}

func TestConvertResponseFormat(t *testing.T) {
t.Run("json_object", func(t *testing.T) {
got := convertResponseFormat(&core.ResponseFormat{Type: "json_object"})
Expand Down
Loading
Loading