c4fde583f5
Apply gofmt -w across the codebase (struct field comment realignment only — no semantic changes) and silence two errcheck warnings on fmt.Sscanf / fmt.Fprintf return values in internal/router/discovery with explicit `_, _ =` discards. Required so `make check` is green before tagging v0.1.0.
272 lines
8.4 KiB
Go
272 lines
8.4 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
"somegit.dev/Owlibou/gnoma/internal/router"
|
|
"somegit.dev/Owlibou/gnoma/internal/security"
|
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
|
"somegit.dev/Owlibou/gnoma/internal/tool"
|
|
)
|
|
|
|
// --- isFailoverable ---
|
|
|
|
func TestIsFailoverable(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"context canceled", context.Canceled, false},
|
|
{"context deadline", context.DeadlineExceeded, false},
|
|
{"wrapped context canceled", fmt.Errorf("wrapped: %w", context.Canceled), false},
|
|
{"bad request 400", &provider.ProviderError{StatusCode: 400, Message: "schema"}, false},
|
|
{"too large 413", &provider.ProviderError{StatusCode: 413, Message: "too big"}, false},
|
|
{"auth 401", &provider.ProviderError{StatusCode: 401, Message: "unauthorized"}, true},
|
|
{"forbidden 403", &provider.ProviderError{StatusCode: 403, Message: "forbidden"}, true},
|
|
{"rate limit 429", &provider.ProviderError{StatusCode: 429, Message: "rate"}, true},
|
|
{"server error 500", &provider.ProviderError{StatusCode: 500, Message: "boom"}, true},
|
|
{"subprocess auth error", errors.New("subprocess: exit status 1: Error: Invalid API key"), true},
|
|
{"generic network error", errors.New("connection reset"), true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isFailoverable(tc.err); got != tc.want {
|
|
t.Errorf("isFailoverable(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- shortFailReason ---
|
|
|
|
func TestShortFailReason(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil",
|
|
err: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "simple message",
|
|
err: errors.New("bad thing"),
|
|
want: "bad thing",
|
|
},
|
|
{
|
|
name: "subprocess wrapper stripped",
|
|
err: errors.New("subprocess: exit status 1: Error: Invalid API key. Try again."),
|
|
want: "Invalid API key. Try again.",
|
|
},
|
|
{
|
|
name: "newlines collapsed",
|
|
err: errors.New("first line\nsecond line"),
|
|
want: "first line second line",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := shortFailReason(tc.err); got != tc.want {
|
|
t.Errorf("shortFailReason() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShortFailReason_Truncates(t *testing.T) {
|
|
long := strings.Repeat("x", 500)
|
|
got := shortFailReason(errors.New(long))
|
|
if len(got) > 160 {
|
|
t.Errorf("expected truncation to <= 160 chars, got %d", len(got))
|
|
}
|
|
if !strings.HasSuffix(got, "...") {
|
|
t.Errorf("expected ellipsis suffix, got %q", got[len(got)-10:])
|
|
}
|
|
}
|
|
|
|
// --- end-to-end failover via router ---
|
|
|
|
// erroringStream returns no events and reports err from Err().
|
|
type erroringStream struct {
|
|
err error
|
|
done bool
|
|
}
|
|
|
|
func (s *erroringStream) Next() bool { return false }
|
|
func (s *erroringStream) Current() stream.Event { return stream.Event{} }
|
|
func (s *erroringStream) Err() error { return s.err }
|
|
func (s *erroringStream) Close() error { s.done = true; return nil }
|
|
|
|
// makeFailoverArm builds a router.Arm wrapping a mock provider whose Stream
|
|
// either returns an erroringStream (when failErr is non-nil) or a successful
|
|
// eventStream with the given text. isCLIAgent puts the arm into the router's
|
|
// tier-0 bucket so tests can deterministically order which arm gets picked
|
|
// first (otherwise the quality bandit makes selection flaky).
|
|
func makeFailoverArm(t *testing.T, id router.ArmID, text string, failErr error, isCLIAgent bool) *router.Arm {
|
|
t.Helper()
|
|
mp := &mockProvider{name: id.Provider()}
|
|
if failErr != nil {
|
|
mp.streams = []stream.Stream{&erroringStream{err: failErr}}
|
|
} else {
|
|
mp.streams = []stream.Stream{
|
|
newEventStream(message.StopEndTurn, id.Model(),
|
|
stream.Event{Type: stream.EventTextDelta, Text: text},
|
|
),
|
|
}
|
|
}
|
|
return &router.Arm{
|
|
ID: id,
|
|
Provider: security.WrapProvider(mp, nil),
|
|
ModelName: id.Model(),
|
|
IsCLIAgent: isCLIAgent,
|
|
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 32000},
|
|
}
|
|
}
|
|
|
|
func TestEngine_FailsOver_OnConsumptionTimeAuthError(t *testing.T) {
|
|
rtr := router.New(router.Config{})
|
|
// vibe is tier-0 (CLI agent); claude is tier-2 (API). Router picks vibe
|
|
// first; on exclusion after failover, claude is the only remaining arm.
|
|
rtr.RegisterArm(makeFailoverArm(t, router.NewArmID("subprocess", "vibe"), "",
|
|
errors.New("subprocess: exit status 1: Error: Invalid API key"), true))
|
|
rtr.RegisterArm(makeFailoverArm(t, router.NewArmID("anthropic", "claude"), "Hello world", nil, false))
|
|
|
|
e, err := New(Config{
|
|
Provider: secureMock(&mockProvider{name: "fallback"}),
|
|
Router: rtr,
|
|
Tools: tool.NewRegistry(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
var events []stream.Event
|
|
turn, err := e.Submit(context.Background(), "hi", func(evt stream.Event) {
|
|
events = append(events, evt)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
|
|
// Failover event must have fired.
|
|
var failover *stream.Event
|
|
for i := range events {
|
|
if events[i].Type == stream.EventFailover {
|
|
failover = &events[i]
|
|
break
|
|
}
|
|
}
|
|
if failover == nil {
|
|
t.Fatal("expected EventFailover in callback events")
|
|
}
|
|
if !strings.Contains(failover.FailedArm, "vibe") {
|
|
t.Errorf("FailedArm = %q, want one mentioning vibe", failover.FailedArm)
|
|
}
|
|
if !strings.Contains(failover.FailedReason, "Invalid API key") {
|
|
t.Errorf("FailedReason = %q, want one mentioning Invalid API key", failover.FailedReason)
|
|
}
|
|
|
|
// Final response must be from the successful arm.
|
|
if len(turn.Messages) == 0 || turn.Messages[len(turn.Messages)-1].TextContent() != "Hello world" {
|
|
t.Errorf("final text = %q, want %q",
|
|
turn.Messages[len(turn.Messages)-1].TextContent(), "Hello world")
|
|
}
|
|
}
|
|
|
|
func TestEngine_DoesNotFailOver_AfterContentEmitted(t *testing.T) {
|
|
// One arm. Its stream emits text, then errors. The engine must NOT
|
|
// attempt failover (would-be-duplicate output) — content is visible
|
|
// to the user, so the only honest response is to surface the error.
|
|
// Single-arm setup keeps the test deterministic without needing to
|
|
// stub the router's bandit selection.
|
|
rtr := router.New(router.Config{})
|
|
mp := &mockProvider{
|
|
name: "subprocess",
|
|
streams: []stream.Stream{
|
|
&halfFailStream{
|
|
preEvents: []stream.Event{{Type: stream.EventTextDelta, Text: "partial..."}},
|
|
err: errors.New("subprocess: exit status 1: Error: disconnected"),
|
|
},
|
|
},
|
|
}
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("subprocess", "vibe"),
|
|
Provider: security.WrapProvider(mp, nil),
|
|
ModelName: "vibe",
|
|
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 32000},
|
|
})
|
|
|
|
e, _ := New(Config{
|
|
Provider: secureMock(&mockProvider{name: "fallback"}),
|
|
Router: rtr,
|
|
Tools: tool.NewRegistry(),
|
|
})
|
|
|
|
var events []stream.Event
|
|
_, err := e.Submit(context.Background(), "hi", func(evt stream.Event) {
|
|
events = append(events, evt)
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error to bubble up since content was already streamed")
|
|
}
|
|
for _, ev := range events {
|
|
if ev.Type == stream.EventFailover {
|
|
t.Errorf("unexpected EventFailover after content emission")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEngine_DoesNotFailOver_OnContextCancel(t *testing.T) {
|
|
rtr := router.New(router.Config{})
|
|
rtr.RegisterArm(makeFailoverArm(t, router.NewArmID("subprocess", "vibe"), "",
|
|
fmt.Errorf("wrapped: %w", context.Canceled), true))
|
|
rtr.RegisterArm(makeFailoverArm(t, router.NewArmID("anthropic", "claude"), "should-not-run", nil, false))
|
|
|
|
e, _ := New(Config{
|
|
Provider: secureMock(&mockProvider{name: "fallback"}),
|
|
Router: rtr,
|
|
Tools: tool.NewRegistry(),
|
|
})
|
|
|
|
var events []stream.Event
|
|
_, err := e.Submit(context.Background(), "hi", func(evt stream.Event) {
|
|
events = append(events, evt)
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected context-canceled error to surface, got nil")
|
|
}
|
|
for _, ev := range events {
|
|
if ev.Type == stream.EventFailover {
|
|
t.Error("EventFailover must not fire on context.Canceled")
|
|
}
|
|
}
|
|
}
|
|
|
|
// halfFailStream emits preEvents, then reports err from Err().
|
|
type halfFailStream struct {
|
|
preEvents []stream.Event
|
|
idx int
|
|
err error
|
|
}
|
|
|
|
func (s *halfFailStream) Next() bool {
|
|
if s.idx >= len(s.preEvents) {
|
|
return false
|
|
}
|
|
s.idx++
|
|
return true
|
|
}
|
|
func (s *halfFailStream) Current() stream.Event { return s.preEvents[s.idx-1] }
|
|
func (s *halfFailStream) Err() error { return s.err }
|
|
func (s *halfFailStream) Close() error { return nil }
|