Files
gnoma/internal/engine/restore_test.go
T
vikingowl fb42202834 refactor(security): seal SecureProvider via unexported marker method
The router.SecureProvider interface previously required a public
IsSecure() bool method. Any test mock — or future production type —
could satisfy it by returning true, defeating the W1 "only wrapped
providers may flow past the boundary" contract through convention
rather than at the type level.

Replaces IsSecure() bool with an unexported security.Marker interface
that has a single secured() method. Go's method-set semantics key
unexported methods by their defining package, so only types declared in
internal/security can satisfy Marker. *SafeProvider gets the lone
secured() implementation; router.SecureProvider embeds Marker.

The seal forces every test mock that previously implemented IsSecure()
to either (a) be wrapped with security.WrapProvider(mp, nil) at the use
site, or (b) drop the method entirely if the mock never flows through
SecureProvider. 93 use sites across 11 test files were updated via a
per-package secureMock helper. WrapProvider with a nil firewall ref is
a no-op pass-through, so test behavior is unchanged.

Empirically: a type from outside internal/security can declare
`secured()` but the compiler will reject assigning it to
router.SecureProvider because the unexported method belongs to the
other package's namespace. Convention → compile-time guarantee.
2026-05-20 02:04:07 +02:00

253 lines
7.0 KiB
Go

package engine
import (
"context"
"encoding/json"
"testing"
gnomactx "somegit.dev/Owlibou/gnoma/internal/context"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/stream"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
// deferredMockTool implements tool.Tool and tool.DeferrableTool.
type deferredMockTool struct {
name string
}
func (d *deferredMockTool) Name() string { return d.name }
func (d *deferredMockTool) Description() string { return "deferred mock" }
func (d *deferredMockTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (d *deferredMockTool) IsReadOnly() bool { return true }
func (d *deferredMockTool) IsDestructive() bool { return false }
func (d *deferredMockTool) ShouldDefer() bool { return true }
func (d *deferredMockTool) Execute(_ context.Context, _ json.RawMessage) (tool.Result, error) {
return tool.Result{Output: "deferred output"}, nil
}
func TestSetHistory_ReplacesHistory(t *testing.T) {
e, _ := New(Config{
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
})
msgs := []message.Message{
message.NewUserText("hello"),
message.NewAssistantText("hi there"),
}
e.SetHistory(msgs)
got := e.History()
if len(got) != 2 {
t.Fatalf("History() len = %d, want 2", len(got))
}
if got[0].Role != message.RoleUser {
t.Errorf("History()[0].Role = %q, want user", got[0].Role)
}
if got[1].Role != message.RoleAssistant {
t.Errorf("History()[1].Role = %q, want assistant", got[1].Role)
}
}
func TestSetHistory_OverwritesPreviousHistory(t *testing.T) {
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "original"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, _ = e.Submit(context.Background(), "first message", nil)
if len(e.History()) == 0 {
t.Fatal("history should not be empty after Submit")
}
replacement := []message.Message{
message.NewUserText("restored message"),
}
e.SetHistory(replacement)
got := e.History()
if len(got) != 1 {
t.Fatalf("History() len = %d, want 1 after restore", len(got))
}
if got[0].TextContent() != "restored message" {
t.Errorf("History()[0].TextContent() = %q, want %q", got[0].TextContent(), "restored message")
}
}
func TestSetHistory_SyncsContextWindow(t *testing.T) {
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
e, _ := New(Config{
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
Context: ctxWindow,
})
msgs := []message.Message{
message.NewUserText("user turn"),
message.NewAssistantText("assistant turn"),
}
e.SetHistory(msgs)
all := e.ContextWindow().AllMessages()
if len(all) != 2 {
t.Fatalf("ContextWindow().AllMessages() len = %d, want 2", len(all))
}
if all[0].TextContent() != "user turn" {
t.Errorf("AllMessages()[0].TextContent() = %q, want %q", all[0].TextContent(), "user turn")
}
}
func TestSetHistory_SyncsTrackerTokenCount(t *testing.T) {
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
e, _ := New(Config{
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
Context: ctxWindow,
})
// Start with zero tracker usage.
if ctxWindow.Tracker().Used() != 0 {
t.Fatal("tracker should start at zero")
}
msgs := []message.Message{
message.NewUserText("hello world"),
}
e.SetHistory(msgs)
// After SetHistory, tracker should reflect a non-zero estimate.
used := ctxWindow.Tracker().Used()
if used == 0 {
t.Error("tracker should be non-zero after SetHistory with messages")
}
}
func TestSetHistory_NilContextWindow_NoPanic(t *testing.T) {
e, _ := New(Config{
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
// Context intentionally nil
})
msgs := []message.Message{message.NewUserText("safe")}
// Should not panic when no context window is configured.
e.SetHistory(msgs)
if len(e.History()) != 1 {
t.Errorf("History() len = %d, want 1", len(e.History()))
}
}
func TestSetUsage_ReplacesUsage(t *testing.T) {
e, _ := New(Config{
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
})
u := message.Usage{InputTokens: 500, OutputTokens: 250}
e.SetUsage(u)
got := e.Usage()
if got.InputTokens != 500 {
t.Errorf("Usage().InputTokens = %d, want 500", got.InputTokens)
}
if got.OutputTokens != 250 {
t.Errorf("Usage().OutputTokens = %d, want 250", got.OutputTokens)
}
}
func TestSetUsage_OverwritesPreviousUsage(t *testing.T) {
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventUsage, Usage: &message.Usage{InputTokens: 100, OutputTokens: 50}},
stream.Event{Type: stream.EventTextDelta, Text: "hi"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, _ = e.Submit(context.Background(), "hello", nil)
if e.Usage().InputTokens == 0 {
t.Fatal("usage should be non-zero after Submit")
}
restored := message.Usage{InputTokens: 999, OutputTokens: 111}
e.SetUsage(restored)
got := e.Usage()
if got.InputTokens != 999 {
t.Errorf("Usage().InputTokens = %d, want 999", got.InputTokens)
}
if got.OutputTokens != 111 {
t.Errorf("Usage().OutputTokens = %d, want 111", got.OutputTokens)
}
}
func TestSetActivatedTools_DeferredToolIncludedInRequest(t *testing.T) {
reg := tool.NewRegistry()
reg.Register(&deferredMockTool{name: "bash"})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopEndTurn, "mock-model",
stream.Event{Type: stream.EventTextDelta, Text: "done"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
// Before activation: buildRequest should omit "bash" (deferred).
reqBefore := e.buildRequest(context.Background())
for _, td := range reqBefore.Tools {
if td.Name == "bash" {
t.Fatal("deferred tool 'bash' should not appear in request before activation")
}
}
// Restore activated tools.
e.SetActivatedTools(map[string]bool{"bash": true})
// After activation: buildRequest should include "bash".
reqAfter := e.buildRequest(context.Background())
found := false
for _, td := range reqAfter.Tools {
if td.Name == "bash" {
found = true
break
}
}
if !found {
t.Error("deferred tool 'bash' should appear in request after SetActivatedTools")
}
}
func TestSetActivatedTools_EmptyMap_DeactivatesAll(t *testing.T) {
reg := tool.NewRegistry()
reg.Register(&deferredMockTool{name: "bash"})
mp := &mockProvider{name: "test"}
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
// Manually activate, then restore to empty.
e.activatedTools["bash"] = true
e.SetActivatedTools(map[string]bool{})
req := e.buildRequest(context.Background())
for _, td := range req.Tools {
if td.Name == "bash" {
t.Error("deferred tool 'bash' should not appear after SetActivatedTools(empty)")
}
}
}