247 lines
8.4 KiB
Markdown
247 lines
8.4 KiB
Markdown
|
|
# go-agentic Development Guide
|
||
|
|
|
||
|
|
Module: `forge.lthn.ai/core/go-agentic`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Prerequisites
|
||
|
|
|
||
|
|
- Go 1.25 or later (the module declares `go 1.25.5`)
|
||
|
|
- Go workspace containing `../go` and `../go-store` (the `go.mod` replace directives assume sibling directories)
|
||
|
|
- `git` on `$PATH` (required at runtime by `context.go` and `completion.go`)
|
||
|
|
- `claude` on `$PATH` (required at runtime by `service.go` — not needed for tests)
|
||
|
|
- `gh` on `$PATH` and authenticated (required at runtime by `completion.go` CreatePR — not needed for unit tests)
|
||
|
|
- Redis (optional — required only for `RedisStore` and `RedisRegistry` integration tests)
|
||
|
|
|
||
|
|
### Go Workspace Setup
|
||
|
|
|
||
|
|
The repository is part of a Go workspace rooted one level up. If running outside the workspace, the replace directives in `go.mod` must resolve:
|
||
|
|
|
||
|
|
```
|
||
|
|
forge.lthn.ai/core/go → ../go
|
||
|
|
forge.lthn.ai/core/go-store → ../go-store
|
||
|
|
```
|
||
|
|
|
||
|
|
Either work within the workspace or run `go work sync` after cloning adjacent repositories.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Build and Test Commands
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Run all tests
|
||
|
|
go test ./...
|
||
|
|
|
||
|
|
# Run a single named test
|
||
|
|
go test -v -run TestAllowance ./...
|
||
|
|
|
||
|
|
# Run with race detector (recommended before committing)
|
||
|
|
go test -race ./...
|
||
|
|
|
||
|
|
# Run benchmarks
|
||
|
|
go test -bench=. ./...
|
||
|
|
|
||
|
|
# Generate coverage report
|
||
|
|
go test -coverprofile=coverage.out ./...
|
||
|
|
go tool cover -html=coverage.out
|
||
|
|
```
|
||
|
|
|
||
|
|
There is no Taskfile or Makefile in this module. All automation uses plain `go` commands.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Test Patterns
|
||
|
|
|
||
|
|
### Naming Conventions
|
||
|
|
|
||
|
|
Tests use a `_Good`, `_Bad`, `_Ugly` suffix pattern inherited from the Core Go framework:
|
||
|
|
|
||
|
|
- `_Good` — happy path, expected success
|
||
|
|
- `_Bad` — expected error conditions (invalid input, limit exceeded, not found)
|
||
|
|
- `_Ugly` — panics, race conditions, or extreme edge cases
|
||
|
|
|
||
|
|
### File Organisation
|
||
|
|
|
||
|
|
Test files are co-located with their source files. The following test files exist:
|
||
|
|
|
||
|
|
| File | Covers |
|
||
|
|
|---|---|
|
||
|
|
| `allowance_test.go` | MemoryStore CRUD, UsageRecord, MemoryRegistry |
|
||
|
|
| `allowance_edge_test.go` | Boundary conditions: exact limit, one-over, warning threshold |
|
||
|
|
| `allowance_error_test.go` | Error paths in RecordUsage, Check, and ResetAgent via mock store |
|
||
|
|
| `allowance_sqlite_test.go` | SQLiteStore with `:memory:` database |
|
||
|
|
| `allowance_redis_test.go` | RedisStore integration (skip if Redis unavailable) |
|
||
|
|
| `client_test.go` | HTTP client with `httptest.Server` mocks |
|
||
|
|
| `completion_git_test.go` | AutoCommit, CreateBranch, CommitAndSync, GetDiff using real git repos |
|
||
|
|
| `completion_test.go` | Completion type parsing |
|
||
|
|
| `config_test.go` | YAML and .env loading, env var overrides |
|
||
|
|
| `context_git_test.go` | findRelatedCode with real git repos, keyword search, 10-file limit, truncation |
|
||
|
|
| `context_test.go` | BuildTaskContext, GatherRelatedFiles |
|
||
|
|
| `coverage_boost_test.go` | Miscellaneous coverage for edge cases |
|
||
|
|
| `dispatcher_test.go` | Full dispatch flow, allowance rejection, no-agent path, loop cancellation |
|
||
|
|
| `embed_test.go` | Prompt() hit/miss and whitespace trimming |
|
||
|
|
| `events_integration_test.go` | Event emission from Dispatcher and AllowanceService |
|
||
|
|
| `events_test.go` | ChannelEmitter, MultiEmitter |
|
||
|
|
| `lifecycle_test.go` | Full claim -> process -> complete integration; fail/cancel flows; concurrent agents |
|
||
|
|
| `logs_test.go` | StreamLogs polling, context cancellation |
|
||
|
|
| `registry_redis_test.go` | RedisRegistry integration (skip if Redis unavailable) |
|
||
|
|
| `registry_sqlite_test.go` | SQLiteRegistry with `:memory:` database |
|
||
|
|
| `registry_test.go` | MemoryRegistry |
|
||
|
|
| `router_test.go` | Capability matching, load distribution, critical priority, tie-breaking |
|
||
|
|
| `service_test.go` | DefaultServiceOptions, TaskPrompt Set/GetTaskID, TaskCommit fields |
|
||
|
|
| `status_test.go` | GetStatus with nil and mock components, FormatStatus |
|
||
|
|
| `submit_test.go` | SubmitTask validation and creation |
|
||
|
|
|
||
|
|
### Writing Tests
|
||
|
|
|
||
|
|
Use `github.com/stretchr/testify/assert` for non-fatal assertions and `require` for fatal ones:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestAllowance_Check_Good(t *testing.T) {
|
||
|
|
store := NewMemoryStore()
|
||
|
|
svc := NewAllowanceService(store)
|
||
|
|
|
||
|
|
err := store.SetAllowance(&AgentAllowance{
|
||
|
|
AgentID: "agent-1",
|
||
|
|
DailyTokenLimit: 1000,
|
||
|
|
})
|
||
|
|
require.NoError(t, err)
|
||
|
|
|
||
|
|
result, err := svc.Check("agent-1", "claude-sonnet-4-5")
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.True(t, result.Allowed)
|
||
|
|
assert.Equal(t, AllowanceOK, result.Status)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Redis Tests
|
||
|
|
|
||
|
|
Redis tests use a skip-if-no-Redis pattern:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestRedisStore_GetAllowance_Good(t *testing.T) {
|
||
|
|
rs, err := NewRedisStore("localhost:6379")
|
||
|
|
if err != nil {
|
||
|
|
t.Skip("Redis not available:", err)
|
||
|
|
}
|
||
|
|
defer rs.Close()
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Each Redis test uses a unique key prefix to avoid cross-test interference:
|
||
|
|
|
||
|
|
```go
|
||
|
|
rs, _ := NewRedisStore("localhost:6379",
|
||
|
|
WithRedisPrefix("test-"+t.Name()),
|
||
|
|
)
|
||
|
|
defer rs.FlushPrefix(context.Background())
|
||
|
|
```
|
||
|
|
|
||
|
|
### Mock Store Pattern
|
||
|
|
|
||
|
|
Use the `errorStore` mock (defined in `allowance_error_test.go`) to test error paths in `AllowanceService` without a real store:
|
||
|
|
|
||
|
|
```go
|
||
|
|
type errorStore struct{ AllowanceStore }
|
||
|
|
|
||
|
|
func (e *errorStore) GetAllowance(string) (*AgentAllowance, error) {
|
||
|
|
return nil, errors.New("store failure")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Coding Standards
|
||
|
|
|
||
|
|
### Language
|
||
|
|
|
||
|
|
UK English throughout. Use: colour, organisation, behaviour, licence (noun), centre, serialise, recognise. Never use American spellings.
|
||
|
|
|
||
|
|
### Go Style
|
||
|
|
|
||
|
|
- All files must have `package agentic` as the first declaration.
|
||
|
|
- The module does not use `declare(strict_types)` (this is a Go project, not PHP), but all function signatures must have explicit parameter and return types.
|
||
|
|
- Exported types and functions must have Go doc comments.
|
||
|
|
- Error strings use the `"op: what failed"` convention via `log.E(op, "message", err)` from the Core framework.
|
||
|
|
- Sentinel errors (such as `ErrNoEligibleAgent`) are package-level `var` declarations using `errors.New`.
|
||
|
|
- Defensive copies on all `Get`/`Set` operations in in-memory stores to prevent aliasing.
|
||
|
|
|
||
|
|
### Conventional Commits
|
||
|
|
|
||
|
|
Commit message format:
|
||
|
|
|
||
|
|
```
|
||
|
|
type(scope): short description
|
||
|
|
|
||
|
|
Optional longer body explaining why, not what.
|
||
|
|
|
||
|
|
Co-Authored-By: Virgil <virgil@lethean.io>
|
||
|
|
```
|
||
|
|
|
||
|
|
Types: `feat`, `fix`, `test`, `docs`, `refactor`, `chore`
|
||
|
|
|
||
|
|
Scopes used in this project: `allowance`, `registry`, `dispatch`, `cli`, `events`, `config`, `test`
|
||
|
|
|
||
|
|
Examples from the commit log:
|
||
|
|
|
||
|
|
```
|
||
|
|
feat(events): Phase 8 — event hooks for task lifecycle and quota notifications
|
||
|
|
feat(dispatch): Phase 7 — priority-ordered dispatch with retry backoff and dead-letter
|
||
|
|
feat(registry): Phase 5 — persistent agent registry (SQLite + Redis + config factory)
|
||
|
|
feat(allowance): add Redis backend for AllowanceStore
|
||
|
|
test: achieve 85.6% coverage with 7 new test files
|
||
|
|
fix(config): change DefaultBaseURL to localhost, annotate reserved fields
|
||
|
|
```
|
||
|
|
|
||
|
|
### Licence
|
||
|
|
|
||
|
|
All source files are licensed under EUPL-1.2. Do not add MIT, Apache, or other licence headers.
|
||
|
|
|
||
|
|
### Co-Author
|
||
|
|
|
||
|
|
Every commit must include:
|
||
|
|
|
||
|
|
```
|
||
|
|
Co-Authored-By: Virgil <virgil@lethean.io>
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Coverage Target
|
||
|
|
|
||
|
|
The project targets 85%+ line coverage. Current coverage after Phase 8 is above 96%. The following are intentionally excluded from coverage:
|
||
|
|
|
||
|
|
- `service.go` — `NewService`, `OnStartup`, `handleTask`, `doCommit`, `doPrompt` require the full Core DI container and a `claude` subprocess. Testing these functions needs either a complete Core bootstrap or interface extraction of the subprocess call.
|
||
|
|
- `completion.go` CreatePR — requires `gh` CLI installed and authenticated. Not suitable for unit tests.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Adding a New Backend
|
||
|
|
|
||
|
|
To add a new `AllowanceStore` or `AgentRegistry` backend:
|
||
|
|
|
||
|
|
1. Create a new file (e.g., `allowance_memcache.go`) implementing all interface methods.
|
||
|
|
2. Add a `Close() error` method if the backend holds resources.
|
||
|
|
3. Add a test file using the same pattern as `allowance_sqlite_test.go` (use an in-memory or mock connection; add a skip guard if the backend requires an external service).
|
||
|
|
4. Add a case to `NewAllowanceStoreFromConfig` or `NewAgentRegistryFromConfig` in `config.go`.
|
||
|
|
5. Add a corresponding config field to `AllowanceConfig` or `RegistryConfig`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Running Integration Tests Locally
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# With Redis running on default port
|
||
|
|
go test -v -run TestRedis ./...
|
||
|
|
go test -v -run TestRedisRegistry ./...
|
||
|
|
|
||
|
|
# With race detector for concurrent-access tests
|
||
|
|
go test -race -v -run TestConcurrent ./...
|
||
|
|
|
||
|
|
# Run only fast unit tests (skips Redis)
|
||
|
|
go test -short ./...
|
||
|
|
```
|
||
|
|
|
||
|
|
The `-short` flag is respected: Redis tests check `testing.Short()` and skip accordingly if you add that guard, though the current pattern skips on connection failure rather than the short flag.
|