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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ jobs:
- name: Check build
run: go build ./...

coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Enforce coverage threshold
run: make coverage-check

vet:
name: Vet
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Thank you for your interest in contributing!

```bash
make test # run tests
make coverage # run coverage check inputs (excludes e2e/examples)
make lint # run linter
make vet # run go vet
make fmt # format code
Expand Down Expand Up @@ -55,6 +56,12 @@ make test-e2e
- Update documentation if you change public APIs
- Write a clear description explaining **what** and **why**

### Test Organization

- Keep tests close to the subject they verify; avoid catch-all files like `coverage_*_test.go`
- Prefer `*_test.go` for behavior tests, `*_internal_test.go` for same-package white-box tests, and `*_transport_test.go` for local protocol/server tests
- Reserve `e2e/` for smoke tests that hit real external APIs and run them via `make test-e2e`

## Reporting Bugs

Open an issue with:
Expand Down
22 changes: 20 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
.PHONY: test lint vet build fmt check clean test-e2e
.PHONY: test lint vet build fmt check clean test-e2e coverage coverage-check

GOLANGCI_LINT_VERSION := v2.1.6
COVERAGE_PACKAGES := $(shell go list ./... | grep -vE '/(e2e|examples|internal/testutil)($$|/)')
COVERAGE_THRESHOLD := 95.0

# Run all tests (excluding e2e)
test:
go test -race -count=1 -timeout 60s $$(go list ./... | grep -v /e2e)

# Run end-to-end tests (requires API keys)
test-e2e:
go test -race -count=1 -timeout 300s ./e2e/...
go test -tags=e2e -race -count=1 -timeout 300s ./e2e/...

# Run coverage excluding e2e, examples, and test-only helper packages
coverage:
go test -count=1 -coverprofile=coverage.out $(COVERAGE_PACKAGES)
@go tool cover -func=coverage.out | tail -n 1

# Enforce minimum coverage threshold for CI
coverage-check: coverage
@pct=$$(go tool cover -func=coverage.out | awk '/^total:/ { gsub(/%/, "", $$3); print $$3 }'); \
awk -v pct="$$pct" -v threshold="$(COVERAGE_THRESHOLD)" 'BEGIN { \
if ((pct + 0) < (threshold + 0)) { \
printf("coverage %.1f%% is below required %.1f%%\n", pct + 0, threshold + 0); \
exit 1; \
} \
printf("coverage %.1f%% meets required %.1f%%\n", pct + 0, threshold + 0); \
}'

# Run linter (same version as CI)
lint:
Expand Down
30 changes: 29 additions & 1 deletion agent_options_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package agentic

import "testing"
import (
"context"
"testing"
)

func TestWithTemperature(t *testing.T) {
cfg := defaultAgentConfig[any]()
Expand Down Expand Up @@ -79,3 +82,28 @@ func TestDefaultAgentConfig(t *testing.T) {
t.Errorf("expected default maxRetries 1, got %d", cfg.retryConfig.MaxRetries)
}
}

func TestHistoryProcessorOptions(t *testing.T) {
proc := HistoryProcessorFunc(func(ctx context.Context, messages []Message) ([]Message, error) {
return append(messages, NewTextMessage(RoleAssistant, "processed")), nil
})

cfg := defaultAgentConfig[any]()
WithHistoryProcessor[any](proc)(&cfg)
if cfg.historyProcessor == nil {
t.Fatal("expected agent history processor to be set")
}

opts := applyRunOptions([]RunOption{WithRunHistoryProcessor(proc)})
if opts.historyProcessor == nil {
t.Fatal("expected run history processor to be set")
}

got, err := opts.historyProcessor.Process(context.Background(), []Message{NewTextMessage(RoleUser, "hello")})
if err != nil {
t.Fatalf("Process: %v", err)
}
if len(got) != 2 || got[1].GetTextContent() != "processed" {
t.Fatalf("unexpected processed messages %#v", got)
}
}
92 changes: 10 additions & 82 deletions agent_extra_test.go → agent_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package agentic_test

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

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

Expand Down Expand Up @@ -40,7 +41,7 @@ func TestAgentDynamicPromptError(t *testing.T) {
if err == nil {
t.Fatal("expected error from dynamic prompt")
}
if !containsStr(err.Error(), "system prompt") {
if !strings.Contains(err.Error(), "system prompt") {
t.Errorf("expected 'system prompt' in error, got %q", err.Error())
}
}
Expand Down Expand Up @@ -113,27 +114,30 @@ func TestAgentWithMessagesContainingSystemPrompt(t *testing.T) {
}

func TestAgentModelRequestError(t *testing.T) {
model := &errorModel{err: fmt.Errorf("api error")}
model := &testutil.StubModel{NameValue: "error-model", Err: fmt.Errorf("api error")}
agent := agentic.NewAgent[any]("test", model)

_, err := agent.Run(context.Background(), "hello", nil)
if err == nil {
t.Fatal("expected error from model request")
}
if !containsStr(err.Error(), "model request") {
if !strings.Contains(err.Error(), "model request") {
t.Errorf("expected 'model request' in error, got %q", err.Error())
}
}

func TestAgentNoChoicesInResponse(t *testing.T) {
model := &emptyChoicesModel{}
model := &testutil.StubModel{
NameValue: "empty-model",
Response: &agentic.ChatResponse{Choices: nil},
}
agent := agentic.NewAgent[any]("test", model)

_, err := agent.Run(context.Background(), "hello", nil)
if err == nil {
t.Fatal("expected error for no choices")
}
if !containsStr(err.Error(), "no choices") {
if !strings.Contains(err.Error(), "no choices") {
t.Errorf("expected 'no choices' in error, got %q", err.Error())
}
}
Expand Down Expand Up @@ -173,45 +177,6 @@ func TestAgentAddToolPanicsOnDuplicate(t *testing.T) {
agent.AddTool(tool, handler) // should panic
}

func TestRunOutputStructuredFull(t *testing.T) {
type Result struct {
Name string `json:"name" description:"Name"`
Score int `json:"score" description:"Score"`
}

outputSpec := agentic.NewToolOutput[Result]("Provide result")

model := test.NewTestModel(
test.ModelResponse{
ToolCalls: []agentic.ToolUse{
{
ID: "c1",
Name: "__output__",
Input: map[string]interface{}{
"name": "test",
"score": float64(100),
},
},
},
},
)

agent := agentic.NewAgent[any]("test", model)

result, err := agentic.RunOutput[any, Result](
context.Background(), agent, "go", nil, outputSpec,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Output.Name != "test" {
t.Errorf("expected name %q, got %q", "test", result.Output.Name)
}
if result.Output.Score != 100 {
t.Errorf("expected score 100, got %d", result.Output.Score)
}
}

func TestFirstNonNil(t *testing.T) {
// Tested indirectly through agent options, but let's verify behavior
// by using run options that override agent options
Expand Down Expand Up @@ -260,40 +225,3 @@ func TestAgentNewAgentDynamicWithOptions(t *testing.T) {
t.Errorf("expected %q, got %q", "ok", result.Output)
}
}

// Helper models for testing error paths

type errorModel struct {
err error
}

func (m *errorModel) Request(ctx context.Context, req *agentic.ChatRequest) (*agentic.ChatResponse, error) {
return nil, m.err
}

func (m *errorModel) Name() string { return "error-model" }

type emptyChoicesModel struct{}

func (m *emptyChoicesModel) Request(ctx context.Context, req *agentic.ChatRequest) (*agentic.ChatResponse, error) {
return &agentic.ChatResponse{Choices: nil}, nil
}

func (m *emptyChoicesModel) Name() string { return "empty-model" }

func containsStr(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstring(s, sub))
}

func containsSubstring(s, sub string) bool {
return errors.New(s).Error() != "" && len(sub) > 0 && findSubstring(s, sub)
}

func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
50 changes: 0 additions & 50 deletions agent_toolset_test.go

This file was deleted.

Loading
Loading