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
68 changes: 68 additions & 0 deletions .ai/prompts/tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

# Tests

- Prefer `go test <pkg>` or `-run <TestName>` over `go test ./...` (slow).
- Use table-driven tests covering happy path, failure, and edge cases.
- Skip trivial getters/setters unless they contain non-trivial logic.
- Use `testify/assert` with `assert.*(t, *)` or `require.*(t, *)` directly, not `assert.New(t)`.
- Use `testify/suite` for related test groups, and use `s.*(*, *)` when asserting.
- Use the testify `EXPECT` method for mocks; avoid `mock.Anything`.
- Use `assert.AnError` if needed, and `assert.Equal` for error assertions.
- Assert full maps/structs/slices/arrays, not individual fields.
- Prefer direct value assertions over `mock.MatchedBy`; use it only for dynamically-generated args.
- Every mock must use `.Once()` or `.Times()`, only use `.Maybe()` when necessary; avoid no-op expectations.
- Name tests `Test<FunctionName>_[Optional]`; use table style or sub-tests for multiple cases.
- Don't use `assert.*` with `if` statements; use `assert.*` directly for clarity and better failure messages.
- Use `t.Run()` for sub-tests when testing multiple cases for the same function, and use table-driven tests for multiple cases with similar setup/assertions. Avoid writing separate test functions for each case when they share common logic.
- The basic table-driven test pattern is:

```go
import (
[system packages]

[third-party packages]

[internal packages]
)

func TestFunction(t *testing.T) {
// The name should start with `mock` to indicate it's a mocked function.
var (
ctx context.Context
mockFunc *mocks.MockedInterface
)

beforeEach := func() {
mockFunc = mocks.NewMockedInterface(t)
}

tests := []struct {
name string
input any
setup func()
expect any
expectError error
}{
{
name: "should do something",
input: someInput,
setup: func() {
mockFunc.EXPECT().SomeMethod(someArgs).Return(someResult, nil).Once()
},
expect: someResult,
expectError: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Comment thread
hwbrzzl marked this conversation as resolved.
beforeEach()
tt.setup()

result, err := FunctionUnderTest(tt.input)
assert.Equal(t, tt.expect, result)
assert.Equal(t, tt.expectError, err)
})
}
}
```
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ golangci-lint run # lint
- Cache/Session/Queue: `goravel/redis`
- Storage: `goravel/s3`, `oss`, `cos`, `minio`

## AI Agent Code Rule
## Code Rules

- Should use `any` instead of `interface{}`.
- Avoid adding `mock.Anything` when writing test cases.
- Use the testify `EXPECT` method when writing test cases.
- Don't modify the files in the `mocks` directory, run the `go tool mockery` command to regenerate mocks instead if needed.
- Don't run `go test ./...` if unnecessary, the command is a bit slow, run `go test` with the specific package or test function instead.
- Use `any` instead of `interface{}`.
- Never edit `mocks/` directly; run `go tool mockery` to regenerate.
- Follow standard Go formatting/naming; add comments where logic isn't self-evident. Go version is in go.mod.

## Tests

When writing tests, use the rules in `.ai/prompts/tests.md` for guidance.
42 changes: 37 additions & 5 deletions ai/application.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,52 @@
package ai

import (
"context"

contractsai "github.com/goravel/framework/contracts/ai"
"github.com/goravel/framework/contracts/config"
)

var _ contractsai.AI = (*Application)(nil)

// Application is the AI manager implementation.
type Application struct {
ctx context.Context
config contractsai.Config
resolver *ProviderResolver
}

func NewApplication(config config.Config) *Application {
return &Application{}
func NewApplication(ctx context.Context, config contractsai.Config) *Application {
return &Application{
ctx: ctx,
config: config,
resolver: NewProviderResolver(config),
}
}

func (r *Application) Agent(agent contractsai.Agent, options ...contractsai.Option) (contractsai.Conversation, error) {
return &conversation{}, nil
opts := make(map[string]any)
for _, option := range options {
option(opts)
}

providerName, _ := opts[contractsai.OptionProvider].(string)
if providerName == "" {
providerName = r.config.Default
}

provider, err := r.resolver.New(providerName)
if err != nil {
return nil, err
}

model, _ := opts[contractsai.OptionModel].(string)

return NewConversation(r.ctx, agent, provider, model), nil
}

func (r *Application) WithContext(ctx context.Context) contractsai.AI {
return &Application{
ctx: ctx,
config: r.config,
resolver: r.resolver,
}
}
174 changes: 174 additions & 0 deletions ai/application_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package ai

import (
"context"
"testing"

"github.com/stretchr/testify/assert"

contractsai "github.com/goravel/framework/contracts/ai"
mocksai "github.com/goravel/framework/mocks/ai"
)

func TestApplication_Agent(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
promptInput string
options []contractsai.Option
setupConfig func(t *testing.T) (contractsai.Config, *mocksai.Provider)
expectedModel string
responseText string
expectResponse bool
promptErr error
expectPromptErr bool
}{
{
name: "default provider",
promptInput: "ping",
setupConfig: func(t *testing.T) (contractsai.Config, *mocksai.Provider) {
provider := mocksai.NewProvider(t)
return contractsai.Config{
Default: "default",
Providers: map[string]contractsai.ProviderConfig{
"default": {Via: provider},
},
}, provider
},
responseText: "ok",
expectResponse: true,
},
{
name: "provider override",
promptInput: "override",
options: []contractsai.Option{WithProvider("alternative")},
setupConfig: func(t *testing.T) (contractsai.Config, *mocksai.Provider) {
defaultProvider := mocksai.NewProvider(t)
alternativeProvider := mocksai.NewProvider(t)
return contractsai.Config{
Default: "default",
Providers: map[string]contractsai.ProviderConfig{
"default": {Via: defaultProvider},
"alternative": {Via: alternativeProvider},
},
}, alternativeProvider
},
responseText: "override",
expectResponse: true,
},
{
name: "model option",
promptInput: "any",
options: []contractsai.Option{WithModel("custom-model")},
setupConfig: func(t *testing.T) (contractsai.Config, *mocksai.Provider) {
provider := mocksai.NewProvider(t)
return contractsai.Config{
Default: "default",
Providers: map[string]contractsai.ProviderConfig{
"default": {Via: provider},
},
}, provider
},
expectedModel: "custom-model",
responseText: "modelled",
expectResponse: true,
},
{
name: "provider error",
promptInput: "fail",
setupConfig: func(t *testing.T) (contractsai.Config, *mocksai.Provider) {
provider := mocksai.NewProvider(t)
return contractsai.Config{
Default: "default",
Providers: map[string]contractsai.ProviderConfig{
"default": {Via: provider},
},
}, provider
},
expectPromptErr: true,
promptErr: assert.AnError,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, provider := tt.setupConfig(t)
agent := mocksai.NewAgent(t)
agent.EXPECT().Messages().Return(nil).Once()

app := NewApplication(ctx, config)
conv, err := app.Agent(agent, tt.options...)
assert.NoError(t, err)

convImpl, ok := conv.(*conversation)
assert.True(t, ok)

expectedPrompt := contractsai.AgentPrompt{
Agent: convImpl,
Input: tt.promptInput,
Model: tt.expectedModel,
}

var response *mocksai.Response
if tt.expectResponse {
response = mocksai.NewResponse(t)
response.EXPECT().Text().Return(tt.responseText).Once()
}

provider.EXPECT().
Prompt(ctx, expectedPrompt).
Return(response, tt.promptErr).
Once()

resp, err := conv.Prompt(tt.promptInput)
if tt.expectPromptErr {
assert.Equal(t, tt.promptErr, err)
assert.Nil(t, resp)
return
}
assert.NoError(t, err)
assert.Equal(t, response, resp)
})
}
}

func TestApplication_Agent_ResolverError(t *testing.T) {
ctx := context.Background()
config := contractsai.Config{
Default: "default",
Providers: map[string]contractsai.ProviderConfig{
"default": {
Via: func() (contractsai.Provider, error) {
return nil, assert.AnError
},
},
},
}

app := NewApplication(ctx, config)
_, err := app.Agent(mocksai.NewAgent(t))
assert.Equal(t, assert.AnError, err)
}

type testCtxKey string

func TestApplication_WithContext(t *testing.T) {
origCtx := context.WithValue(context.Background(), testCtxKey("orig"), true)
provider := mocksai.NewProvider(t)
config := contractsai.Config{
Default: "default",
Providers: map[string]contractsai.ProviderConfig{
"default": {Via: provider},
},
}

app := NewApplication(origCtx, config)
newCtx := context.WithValue(context.Background(), testCtxKey("orig"), false)
aiWithCtx := app.WithContext(newCtx)
aiImpl, ok := aiWithCtx.(*Application)
assert.True(t, ok)

assert.Same(t, newCtx, aiImpl.ctx)
assert.Same(t, app.resolver, aiImpl.resolver)
assert.Equal(t, app.config, aiImpl.config)
}
40 changes: 34 additions & 6 deletions ai/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,48 @@ package ai

import (
"context"
"slices"

contractsai "github.com/goravel/framework/contracts/ai"
)

type conversation struct {
ctx context.Context
agent contractsai.Agent
messages []contractsai.Message
provider contractsai.Provider
model string
}

func (r *conversation) Prompt(ctx context.Context, input string) (contractsai.Response, error) {
return nil, nil
func NewConversation(ctx context.Context, agent contractsai.Agent, provider contractsai.Provider, model string) *conversation {
return &conversation{
ctx: ctx,
agent: agent,
messages: slices.Clone(agent.Messages()),
provider: provider,
model: model,
}
}

func (r *conversation) Messages() []contractsai.Message {
return nil
}
func (r *conversation) Instructions() string { return r.agent.Instructions() }
func (r *conversation) Messages() []contractsai.Message { return r.messages }
Comment thread
hwbrzzl marked this conversation as resolved.

func (r *conversation) Prompt(input string) (contractsai.Response, error) {
resp, err := r.provider.Prompt(r.ctx, contractsai.AgentPrompt{
Agent: r,
Input: input,
Model: r.model,
})
if err != nil {
return nil, err
}

func (r *conversation) Reset() {
r.messages = append(r.messages,
contractsai.Message{Role: contractsai.RoleUser, Content: input},
contractsai.Message{Role: contractsai.RoleAssistant, Content: resp.Text()},
)

return resp, nil
}

func (r *conversation) Reset() { r.messages = slices.Clone(r.agent.Messages()) }
Loading
Loading