mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
1986 lines
87 KiB
Go
1986 lines
87 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reasonix/internal/state/sessionstore"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/contract/event"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/contract/tool"
|
|
"reasonix/internal/ext/extension"
|
|
"reasonix/internal/ext/extension/dispatch"
|
|
"reasonix/internal/ext/extension/protocol"
|
|
)
|
|
|
|
// Agent-loop wiring tests. The dispatcher under test is real; only
|
|
// its sidecar client is faked, so every assertion exercises the actual
|
|
// dispatch ruling logic (chain walk, strict replacement decode, error
|
|
// policy). Each intercept point is covered for: continue (no-op), block,
|
|
// replace (the substituted value is what the host uses), a required
|
|
// extension's failure (operation fails), and an optional extension's failure
|
|
// (warn + continue).
|
|
|
|
const extTestPlugin = "fake"
|
|
|
|
type extRecordedCall struct {
|
|
event protocol.InterceptEvent
|
|
payload json.RawMessage
|
|
}
|
|
|
|
// fakeDispatchClient is a scriptable dispatch.Client recording every call.
|
|
// A nil interceptFn answers continue.
|
|
type fakeDispatchClient struct {
|
|
mu sync.Mutex
|
|
interceptFn func(event protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error)
|
|
intercepts []extRecordedCall
|
|
notifies []extRecordedCall
|
|
}
|
|
|
|
func (f *fakeDispatchClient) Intercept(_ context.Context, event protocol.InterceptEvent, payload json.RawMessage, _ time.Duration) (protocol.InterceptResult, error) {
|
|
f.mu.Lock()
|
|
f.intercepts = append(f.intercepts, extRecordedCall{event: event, payload: append(json.RawMessage(nil), payload...)})
|
|
fn := f.interceptFn
|
|
f.mu.Unlock()
|
|
if fn == nil {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
return fn(event, payload)
|
|
}
|
|
|
|
func (f *fakeDispatchClient) TryNotifyEvent(event protocol.InterceptEvent, payload json.RawMessage) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.notifies = append(f.notifies, extRecordedCall{event: event, payload: append(json.RawMessage(nil), payload...)})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDispatchClient) notifyCountFor(event protocol.InterceptEvent) int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
n := 0
|
|
for _, call := range f.notifies {
|
|
if call.event == event {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// interceptPayloadFor returns the decoded payload of the first intercept call
|
|
// for event, for assertions about what the host sent.
|
|
func (f *fakeDispatchClient) interceptPayloadFor(event protocol.InterceptEvent, out any) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, call := range f.intercepts {
|
|
if call.event == event {
|
|
return json.Unmarshal(call.payload, out) == nil
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extWarnRecorder collects dispatcher warnings (optional-extension failures).
|
|
type extWarnRecorder struct {
|
|
mu sync.Mutex
|
|
msgs []string
|
|
}
|
|
|
|
func (w *extWarnRecorder) warn(msg string) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.msgs = append(w.msgs, msg)
|
|
}
|
|
|
|
func (w *extWarnRecorder) contains(substr string) bool {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
for _, msg := range w.msgs {
|
|
if strings.Contains(msg, substr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// newExtDispatcher builds a dispatcher whose chain lists the fake plugin at
|
|
// every given point. required=true marks the plugin required-class (manifest
|
|
// required:true), so its failures fail the operation.
|
|
func newExtDispatcher(client dispatch.Client, required bool, warn func(string), points ...extension.InterceptorPoint) *dispatch.Dispatcher {
|
|
return newExtSlotDispatcher(client, required, warn, points, nil)
|
|
}
|
|
|
|
// newExtSlotDispatcher builds a dispatcher with the fake plugin chained at
|
|
// the given points and owning the given replacement slots (slot → plugin ID).
|
|
// A slot owner is required-class by definition, independent of required.
|
|
func newExtSlotDispatcher(client dispatch.Client, required bool, warn func(string), points []extension.InterceptorPoint, slots map[extension.Slot]string) *dispatch.Dispatcher {
|
|
chain := map[extension.InterceptorPoint][]extension.Contribution{}
|
|
for _, point := range points {
|
|
chain[point] = []extension.Contribution{{
|
|
Kind: extension.KindInterceptor,
|
|
ID: string(point),
|
|
Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: extTestPlugin},
|
|
}}
|
|
}
|
|
replacements := map[extension.Slot]extension.ContributionSource{}
|
|
for slot, plugin := range slots {
|
|
replacements[slot] = extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: plugin}
|
|
}
|
|
requiredSet := map[string]bool{}
|
|
if required {
|
|
requiredSet[extTestPlugin] = true
|
|
}
|
|
return dispatch.New(chain, replacements, func(string) dispatch.Client { return client }, requiredSet, dispatch.Options{Warn: warn})
|
|
}
|
|
|
|
// replaceWith marshals v as the replacement payload of a replace ruling.
|
|
func replaceWith(t *testing.T, v any) protocol.InterceptResult {
|
|
t.Helper()
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal replacement: %v", err)
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: raw}
|
|
}
|
|
|
|
func blockWith(reason string) protocol.InterceptResult {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: reason}
|
|
}
|
|
|
|
// recordingTool is a Tool stand-in that records the args it executed with.
|
|
type recordingTool struct {
|
|
name string
|
|
readOnly bool
|
|
execs int
|
|
gotArgs string
|
|
}
|
|
|
|
func (r *recordingTool) Name() string { return r.name }
|
|
func (r *recordingTool) Description() string { return "" }
|
|
func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
|
func (r *recordingTool) ReadOnly() bool { return r.readOnly }
|
|
func (r *recordingTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
|
|
r.execs++
|
|
r.gotArgs = string(args)
|
|
return r.name + " ok", nil
|
|
}
|
|
|
|
// sessionContents flattens the session's message contents for substring
|
|
// assertions.
|
|
func sessionContents(s *sessionstore.Session) string {
|
|
var b strings.Builder
|
|
for _, m := range s.Messages {
|
|
b.WriteString(m.Content)
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func assistantMessages(s *sessionstore.Session) []provider.Message {
|
|
var out []provider.Message
|
|
for _, m := range s.Messages {
|
|
if m.Role == provider.RoleAssistant {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func requestContents(req provider.Request) string {
|
|
var b strings.Builder
|
|
for _, m := range req.Messages {
|
|
b.WriteString(string(m.Role))
|
|
b.WriteByte(':')
|
|
b.WriteString(m.Content)
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// agent.before_start
|
|
|
|
func TestAgentBeforeStartContinue(t *testing.T) {
|
|
client := &fakeDispatchClient{}
|
|
d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(mp.requests) != 1 {
|
|
t.Fatalf("requests = %d, want 1", len(mp.requests))
|
|
}
|
|
var payload dispatch.AgentStartPayload
|
|
if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
|
|
t.Fatal("agent.before_start intercept did not fire")
|
|
}
|
|
if payload.Model != "p" || payload.ToolCount != 0 {
|
|
t.Fatalf("payload = %+v, want model p and 0 tools", payload)
|
|
}
|
|
if n := client.notifyCountFor(protocol.EventAgentBeforeStart); n != 1 {
|
|
t.Fatalf("before_start events = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestAgentBeforeStartBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventAgentBeforeStart {
|
|
return blockWith("no runs today"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "no runs today") {
|
|
t.Fatalf("Run err = %v, want the block reason", err)
|
|
}
|
|
if len(mp.requests) != 0 {
|
|
t.Fatalf("blocked run still hit the provider: %d requests", len(mp.requests))
|
|
}
|
|
if len(sess.Messages) != 1 {
|
|
t.Fatalf("session = %d messages, want only the system message (turn never appended)", len(sess.Messages))
|
|
}
|
|
}
|
|
|
|
func TestAgentBeforeStartReplace(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventAgentBeforeStart {
|
|
return replaceWith(t, dispatch.AgentStartPayload{Model: "other", ToolCount: 3, SessionID: "s1"}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
// The payload is informational: a replacement validates but does not alter
|
|
// the run.
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAgentBeforeStartFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required fails the run", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkDone}}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at agent.before_start") {
|
|
t.Fatalf("Run err = %v, want the required failure", err)
|
|
}
|
|
if len(mp.requests) != 0 {
|
|
t.Fatalf("failed run still hit the provider: %d requests", len(mp.requests))
|
|
}
|
|
})
|
|
t.Run("optional warns and proceeds", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointAgentBeforeStart)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSetExtensionsInstallsAfterConstruction(t *testing.T) {
|
|
client := &fakeDispatchClient{}
|
|
d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{}, event.Discard)
|
|
a.SetExtensions(d)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
var payload dispatch.AgentStartPayload
|
|
if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
|
|
t.Fatal("SetExtensions-installed dispatcher did not fire")
|
|
}
|
|
}
|
|
|
|
// context.prepare
|
|
|
|
func TestContextPrepareReplaceIsEphemeral(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventContextPrepare {
|
|
return replaceWith(t, dispatch.ContextPayload{Messages: []protocol.ProviderMessage{
|
|
{Role: protocol.ProviderRoleSystem, Content: "REPLACED SYS"},
|
|
{Role: protocol.ProviderRoleUser, Content: "REPLACED USER"},
|
|
}}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointContextPrepare)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(mp.requests) != 1 {
|
|
t.Fatalf("requests = %d, want 1", len(mp.requests))
|
|
}
|
|
got := requestContents(mp.requests[0])
|
|
if !strings.Contains(got, "REPLACED USER") || strings.Contains(got, "hello") {
|
|
t.Fatalf("request messages = %q, want the replacement only", got)
|
|
}
|
|
// Ephemerality: the session log keeps the original history untouched.
|
|
sc := sessionContents(sess)
|
|
if strings.Contains(sc, "REPLACED USER") || strings.Contains(sc, "REPLACED SYS") {
|
|
t.Fatalf("session mutated by context.prepare replacement:\n%s", sc)
|
|
}
|
|
if !strings.Contains(sc, "hello") {
|
|
t.Fatalf("session lost the user turn:\n%s", sc)
|
|
}
|
|
}
|
|
|
|
func TestContextPrepareBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventContextPrepare {
|
|
return blockWith("context denied"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointContextPrepare)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "context denied") {
|
|
t.Fatalf("Run err = %v, want the block reason", err)
|
|
}
|
|
if len(mp.requests) != 0 {
|
|
t.Fatalf("blocked request still hit the provider: %d requests", len(mp.requests))
|
|
}
|
|
}
|
|
|
|
func TestContextPrepareFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required fails the turn", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointContextPrepare)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkDone}}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at context.prepare") {
|
|
t.Fatalf("Run err = %v, want the required failure", err)
|
|
}
|
|
})
|
|
t.Run("optional warns and proceeds", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointContextPrepare)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// provider.request
|
|
|
|
func TestProviderRequestReplace(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderRequest {
|
|
var in dispatch.ProviderRequestPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
in.Request.Messages = append(in.Request.Messages, protocol.ProviderMessage{
|
|
Role: protocol.ProviderRoleUser, Content: "EXTENSION INJECTED",
|
|
})
|
|
return replaceWith(t, in), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderRequest)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if got := requestContents(mp.requests[0]); !strings.Contains(got, "EXTENSION INJECTED") {
|
|
t.Fatalf("request = %q, want the injected message", got)
|
|
}
|
|
if sc := sessionContents(sess); strings.Contains(sc, "EXTENSION INJECTED") {
|
|
t.Fatalf("session mutated by provider.request replacement:\n%s", sc)
|
|
}
|
|
if n := client.notifyCountFor(protocol.EventProviderRequest); n != 1 {
|
|
t.Fatalf("provider.request events = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestProviderRequestBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderRequest {
|
|
return blockWith("request denied"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderRequest)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "request denied") {
|
|
t.Fatalf("Run err = %v, want the block reason", err)
|
|
}
|
|
if len(mp.requests) != 0 {
|
|
t.Fatalf("blocked request still hit the provider: %d requests", len(mp.requests))
|
|
}
|
|
}
|
|
|
|
func TestProviderRequestFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required fails the turn", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderRequest)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkDone}}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at provider.request") {
|
|
t.Fatalf("Run err = %v, want the required failure", err)
|
|
}
|
|
})
|
|
t.Run("optional warns and proceeds", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointProviderRequest)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestProviderRequestReplacementCacheEphemerality is the cache contract: a
|
|
// replacement shapes only the request it ruled on. Two identical agents — one
|
|
// with an extension that injects a message into run 1's request only, one
|
|
// without — must send byte-identical requests on run 2.
|
|
func TestProviderRequestReplacementCacheEphemerality(t *testing.T) {
|
|
streams := func() [][]provider.Chunk {
|
|
return [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "one"}, {Type: provider.ChunkDone}},
|
|
{{Type: provider.ChunkText, Text: "two"}, {Type: provider.ChunkDone}},
|
|
}
|
|
}
|
|
client := &fakeDispatchClient{}
|
|
replaced := 0
|
|
client.interceptFn = func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderRequest && replaced == 0 {
|
|
replaced++
|
|
var in dispatch.ProviderRequestPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
in.Request.Messages = append(in.Request.Messages, protocol.ProviderMessage{
|
|
Role: protocol.ProviderRoleUser, Content: "RUN-1 ONLY",
|
|
})
|
|
return replaceWith(t, in), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderRequest)
|
|
|
|
withExt := &mockProvider{name: "p", streams: streams()}
|
|
a := New(withExt, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
for _, input := range []string{"first", "second"} {
|
|
if err := a.Run(context.Background(), input); err != nil {
|
|
t.Fatalf("Run(%q): %v", input, err)
|
|
}
|
|
}
|
|
if len(withExt.requests) != 2 {
|
|
t.Fatalf("requests = %d, want 2", len(withExt.requests))
|
|
}
|
|
if got := requestContents(withExt.requests[0]); !strings.Contains(got, "RUN-1 ONLY") {
|
|
t.Fatalf("run 1 request = %q, want the injected message", got)
|
|
}
|
|
if got := requestContents(withExt.requests[1]); strings.Contains(got, "RUN-1 ONLY") {
|
|
t.Fatalf("run 2 request leaked the run-1 replacement:\n%s", got)
|
|
}
|
|
|
|
baseline := &mockProvider{name: "p", streams: streams()}
|
|
b := New(baseline, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{}, event.Discard)
|
|
for _, input := range []string{"first", "second"} {
|
|
if err := b.Run(context.Background(), input); err != nil {
|
|
t.Fatalf("baseline Run(%q): %v", input, err)
|
|
}
|
|
}
|
|
gotRun2 := requestContents(withExt.requests[1])
|
|
wantRun2 := requestContents(baseline.requests[1])
|
|
if gotRun2 != wantRun2 {
|
|
t.Fatalf("run 2 request differs from the no-extension baseline:\ngot:\n%s\nwant:\n%s", gotRun2, wantRun2)
|
|
}
|
|
}
|
|
|
|
// provider.response
|
|
|
|
func TestProviderResponseReplaceIsTranscript(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderResponse {
|
|
return replaceWith(t, dispatch.ProviderResponsePayload{Text: "REPLACED ANSWER", Reasoning: "replaced reasoning"}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderResponse)
|
|
mp := &mockProvider{name: "p", streams: [][]provider.Chunk{
|
|
{
|
|
{Type: provider.ChunkReasoning, Text: "ORIGINAL REASONING", ReasoningID: "rs_original", ReasoningStatus: "completed"},
|
|
{Type: provider.ChunkText, Text: "ORIGINAL ANSWER"},
|
|
{Type: provider.ChunkDone},
|
|
},
|
|
{{Type: provider.ChunkText, Text: "second"}, {Type: provider.ChunkDone}},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "one"); err != nil {
|
|
t.Fatalf("Run one: %v", err)
|
|
}
|
|
assistants := assistantMessages(sess)
|
|
if len(assistants) != 1 || assistants[0].Content != "REPLACED ANSWER" {
|
|
t.Fatalf("assistant turn = %+v, want the replaced answer persisted", assistants)
|
|
}
|
|
if assistants[0].ReasoningContent != "replaced reasoning" {
|
|
t.Fatalf("assistant reasoning = %q, want the replaced reasoning", assistants[0].ReasoningContent)
|
|
}
|
|
if assistants[0].ReasoningID != "" || assistants[0].ReasoningStatus != "" {
|
|
t.Fatalf("replaced reasoning retained provider metadata = (%q, %q)", assistants[0].ReasoningID, assistants[0].ReasoningStatus)
|
|
}
|
|
// The transcript contract: the next request replays the replaced turn,
|
|
// never the provider's original text.
|
|
if err := a.Run(context.Background(), "two"); err != nil {
|
|
t.Fatalf("Run two: %v", err)
|
|
}
|
|
got := requestContents(mp.requests[1])
|
|
if !strings.Contains(got, "REPLACED ANSWER") {
|
|
t.Fatalf("run 2 request = %q, want the replaced turn replayed", got)
|
|
}
|
|
if strings.Contains(got, "ORIGINAL ANSWER") {
|
|
t.Fatalf("run 2 request leaked the original provider text:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestProviderResponseBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderResponse {
|
|
return blockWith("response denied"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderResponse)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ORIGINAL ANSWER"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "one")
|
|
if err == nil || !strings.Contains(err.Error(), "response denied") {
|
|
t.Fatalf("Run err = %v, want the block reason", err)
|
|
}
|
|
if n := len(assistantMessages(sess)); n != 0 {
|
|
t.Fatalf("blocked response persisted %d assistant turns, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestProviderResponseFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required fails the turn", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointProviderResponse)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ORIGINAL ANSWER"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "one")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at provider.response") {
|
|
t.Fatalf("Run err = %v, want the required failure", err)
|
|
}
|
|
if n := len(assistantMessages(sess)); n != 0 {
|
|
t.Fatalf("failed response persisted %d assistant turns, want 0", n)
|
|
}
|
|
})
|
|
t.Run("optional warns and persists the original", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointProviderResponse)
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ORIGINAL ANSWER"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "one"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
assistants := assistantMessages(sess)
|
|
if len(assistants) != 1 || assistants[0].Content != "ORIGINAL ANSWER" {
|
|
t.Fatalf("assistant turn = %+v, want the original answer persisted", assistants)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// tool.before
|
|
|
|
func TestToolBeforeContinue(t *testing.T) {
|
|
client := &fakeDispatchClient{}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || !strings.Contains(out.output, "read_file ok") {
|
|
t.Fatalf("outcome = %+v, want the tool to run", out)
|
|
}
|
|
if rec.gotArgs != `{"path":"/x"}` {
|
|
t.Fatalf("tool args = %q, want the original call args", rec.gotArgs)
|
|
}
|
|
if n := client.notifyCountFor(protocol.EventToolBefore); n != 1 {
|
|
t.Fatalf("tool.before events = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestToolBeforeBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return blockWith("tool denied"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || out.output != "blocked: tool denied" {
|
|
t.Fatalf("outcome = %+v, want a blocked tool result with the reason", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatalf("blocked tool executed %d times", rec.execs)
|
|
}
|
|
}
|
|
|
|
func TestToolBeforeReplaceArgs(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return replaceWith(t, dispatch.ToolBeforePayload{Name: "read_file", Arguments: `{"path":"/substituted"}`}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/original"}`})
|
|
if out.errMsg != "" {
|
|
t.Fatalf("outcome = %+v, want success", out)
|
|
}
|
|
if rec.gotArgs != `{"path":"/substituted"}` {
|
|
t.Fatalf("tool args = %q, want the extension-substituted args", rec.gotArgs)
|
|
}
|
|
}
|
|
|
|
func TestToolBeforeReplaceName(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return replaceWith(t, dispatch.ToolBeforePayload{Name: "grep", Arguments: `{"pattern":"x"}`}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
orig := &recordingTool{name: "read_file", readOnly: true}
|
|
substituted := &recordingTool{name: "grep", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(orig)
|
|
reg.Add(substituted)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || !strings.Contains(out.output, "grep ok") {
|
|
t.Fatalf("outcome = %+v, want the substituted tool to run", out)
|
|
}
|
|
if orig.execs != 0 || substituted.execs != 1 {
|
|
t.Fatalf("execs = %d/%d, want the substituted tool only", orig.execs, substituted.execs)
|
|
}
|
|
}
|
|
|
|
func TestToolBeforeInvalidReplacements(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
replacement dispatch.ToolBeforePayload
|
|
want string
|
|
}{
|
|
{"empty arguments", dispatch.ToolBeforePayload{Name: "read_file", Arguments: ""}, "arguments must decode as a JSON object"},
|
|
{"unresolvable name", dispatch.ToolBeforePayload{Name: "no_such_tool", Arguments: `{}`}, "does not resolve"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return replaceWith(t, tc.replacement), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg == "" || !strings.Contains(out.output, "violated the intercept contract") || !strings.Contains(out.output, tc.want) {
|
|
t.Fatalf("outcome = %+v, want a contract-violation error result containing %q", out, tc.want)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatalf("invalid replacement still executed the tool")
|
|
}
|
|
})
|
|
}
|
|
// A replacement that fails the point's DTO (arguments not a JSON object)
|
|
// is a dispatch-level violation: for a required extension the call fails.
|
|
t.Run("DTO violation", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"name":"read_file","arguments":"[1,2]"}`)}, nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg == "" || !strings.Contains(out.output, "violated the intercept contract") {
|
|
t.Fatalf("outcome = %+v, want a dispatch violation error result", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("DTO-violating replacement still executed the tool")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestToolBeforeFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required fails the call", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg == "" || !strings.Contains(out.output, "extension fake failed at tool.before") {
|
|
t.Fatalf("outcome = %+v, want the required failure as the tool result", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("failed extension still let the tool run")
|
|
}
|
|
})
|
|
t.Run("optional warns and runs", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || rec.execs != 1 {
|
|
t.Fatalf("outcome = %+v execs = %d, want the tool to run", out, rec.execs)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// permission.decision
|
|
|
|
func TestPermissionDecisionExtensionAllowOverridesHostDeny(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventPermissionDecision {
|
|
var in dispatch.PermissionPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if in.HostDecision != "deny" {
|
|
t.Errorf("host decision = %q, want deny (host computes first)", in.HostDecision)
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionAllow}, nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
gate := &stubGate{deny: map[string]bool{"edit_file": true}}
|
|
var events []event.Event
|
|
sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: gate, Extensions: d}, sink)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || rec.execs != 1 {
|
|
t.Fatalf("outcome = %+v execs = %d, want the full-trust override to execute", out, rec.execs)
|
|
}
|
|
audit := false
|
|
for _, e := range events {
|
|
if e.Kind == event.Notice && strings.Contains(e.Text, "allowed the tool overriding the host deny") {
|
|
audit = true
|
|
}
|
|
}
|
|
if !audit {
|
|
t.Fatal("full-trust override produced no audit notice")
|
|
}
|
|
if n := client.notifyCountFor(protocol.EventPermissionDecision); n != 1 {
|
|
t.Fatalf("permission.decision events = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionExtensionDenyOverridesHostAllow(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventPermissionDecision {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionDeny}, nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.output, "denied by extension permission policy") {
|
|
t.Fatalf("outcome = %+v, want an extension denial", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("extension-denied tool executed")
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionContinueKeepsHostDeny(t *testing.T) {
|
|
client := &fakeDispatchClient{}
|
|
d := newExtDispatcher(client, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
gate := &stubGate{deny: map[string]bool{"edit_file": true}}
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: gate, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.output, "denied by test policy") {
|
|
t.Fatalf("outcome = %+v, want the host denial to stand", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("host-denied tool executed")
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionBlockAndFailure(t *testing.T) {
|
|
t.Run("block denies", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventPermissionDecision {
|
|
return blockWith("policy says no"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.output, "policy says no") {
|
|
t.Fatalf("outcome = %+v, want the block reason", out)
|
|
}
|
|
})
|
|
t.Run("required failure denies", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.output, "extension fake failed at permission.decision") {
|
|
t.Fatalf("outcome = %+v, want the required failure", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("failed extension still let the tool run")
|
|
}
|
|
})
|
|
t.Run("optional failure keeps the host allow", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointPermissionDecision)
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || rec.execs != 1 {
|
|
t.Fatalf("outcome = %+v execs = %d, want the host allow to stand", out, rec.execs)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// tool.after
|
|
|
|
func TestToolAfterReplaceResult(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolAfter {
|
|
return replaceWith(t, dispatch.ToolAfterPayload{
|
|
Name: "read_file", Arguments: `{"path":"/x"}`, Result: "EXTENSION RESULT",
|
|
}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolAfter)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || !strings.Contains(out.output, "EXTENSION RESULT") {
|
|
t.Fatalf("outcome = %+v, want the replaced result", out)
|
|
}
|
|
if strings.Contains(out.output, "read_file ok") {
|
|
t.Fatalf("outcome leaked the original result: %q", out.output)
|
|
}
|
|
}
|
|
|
|
func TestToolAfterReplaceClearsError(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolAfter {
|
|
var in dispatch.ToolAfterPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if !in.IsError {
|
|
t.Errorf("payload IsError = false, want true for a failed tool")
|
|
}
|
|
return replaceWith(t, dispatch.ToolAfterPayload{
|
|
Name: "read_file", Arguments: `{"path":"/x"}`, Result: "RECOVERED BY EXTENSION",
|
|
}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolAfter)
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "read_file", readOnly: true, err: errors.New("boom")})
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || !strings.Contains(out.output, "RECOVERED BY EXTENSION") {
|
|
t.Fatalf("outcome = %+v, want the failure converted to the replaced success", out)
|
|
}
|
|
}
|
|
|
|
func TestToolAfterBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolAfter {
|
|
return blockWith("result withheld"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolAfter)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg == "" || !strings.Contains(out.output, "result withheld") {
|
|
t.Fatalf("outcome = %+v, want an error tool result with the reason", out)
|
|
}
|
|
if rec.execs != 1 {
|
|
t.Fatalf("the tool itself must still have run (block only withholds the result), execs = %d", rec.execs)
|
|
}
|
|
}
|
|
|
|
func TestToolAfterFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required converts the result to the failure", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolAfter)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg == "" || !strings.Contains(out.output, "extension fake failed at tool.after") {
|
|
t.Fatalf("outcome = %+v, want the required failure as the tool result", out)
|
|
}
|
|
})
|
|
t.Run("optional warns and keeps the result", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointToolAfter)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.errMsg != "" || !strings.Contains(out.output, "read_file ok") {
|
|
t.Fatalf("outcome = %+v, want the original result", out)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// compaction.prepare / compaction.complete
|
|
|
|
// newCompactionAgent builds an agent whose session has a foldable middle
|
|
// (large assistant turns) so CompactNow always finds a region, with the
|
|
// summarizer scripted to answer "SUMMARY TEXT". The recent tail stays small so
|
|
// the content-driven candidate lands well under compact_ratio and the 50% ceiling.
|
|
func newCompactionAgent(t *testing.T, d *dispatch.Dispatcher) (*mockProvider, *Agent) {
|
|
t.Helper()
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "## Summary\nSUMMARY TEXT"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
big := strings.Repeat("a", 8000)
|
|
sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
|
|
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
|
|
sess.Add(provider.Message{Role: provider.RoleUser, Content: "more"})
|
|
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
|
|
sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
|
|
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
|
|
return mp, New(mp, tool.NewRegistry(), sess, Options{
|
|
ContextWindow: 50_000, CompactRatio: 0.85, Extensions: d,
|
|
}, event.Discard)
|
|
}
|
|
|
|
func TestCompactionPrepareBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventCompactionPrepare {
|
|
return blockWith("compaction denied"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointCompactionPrepare)
|
|
mp, a := newCompactionAgent(t, d)
|
|
before := len(a.Session().Messages)
|
|
_, err := a.CompactNow(context.Background(), CompactRequest{})
|
|
if err == nil || !strings.Contains(err.Error(), "compaction denied") {
|
|
t.Fatalf("CompactNow err = %v, want the block reason", err)
|
|
}
|
|
if len(mp.requests) != 0 {
|
|
t.Fatalf("blocked compaction still called the summarizer: %d requests", len(mp.requests))
|
|
}
|
|
if len(a.Session().Messages) != before {
|
|
t.Fatal("blocked compaction rewrote the session")
|
|
}
|
|
}
|
|
|
|
func TestCompactionPrepareFailurePolicy(t *testing.T) {
|
|
boom := errors.New("sidecar timeout")
|
|
t.Run("required skips the pass with the failure", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointCompactionPrepare)
|
|
mp, a := newCompactionAgent(t, d)
|
|
before := len(a.Session().Messages)
|
|
_, err := a.CompactNow(context.Background(), CompactRequest{})
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at compaction.prepare") {
|
|
t.Fatalf("CompactNow err = %v, want the required failure", err)
|
|
}
|
|
if len(mp.requests) != 0 || len(a.Session().Messages) != before {
|
|
t.Fatal("failed compaction still ran the summarizer or rewrote the session")
|
|
}
|
|
})
|
|
t.Run("optional warns and folds", func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, boom
|
|
}}
|
|
warns := &extWarnRecorder{}
|
|
d := newExtDispatcher(client, false, warns.warn, extension.PointCompactionPrepare)
|
|
_, a := newCompactionAgent(t, d)
|
|
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
|
t.Fatalf("CompactNow: %v", err)
|
|
}
|
|
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "archived, not summarized") {
|
|
t.Fatalf("projection missing the summary:\n%.200q", sc)
|
|
}
|
|
if !warns.contains("skipping this optional extension") {
|
|
t.Fatalf("warnings = %v, want an optional-extension skip warning", warns.msgs)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCompactionCompleteReplace(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventCompactionComplete {
|
|
var in dispatch.CompactionCompletePayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if !strings.Contains(in.Summary, "archived, not summarized") {
|
|
t.Errorf("complete payload summary = %q, want the produced summary", in.Summary)
|
|
}
|
|
return replaceWith(t, dispatch.CompactionCompletePayload{Summary: "EXTENSION SUMMARY"}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointCompactionComplete)
|
|
_, a := newCompactionAgent(t, d)
|
|
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
|
t.Fatalf("CompactNow: %v", err)
|
|
}
|
|
sc := joinContents(visibleContext(a))
|
|
if !strings.Contains(sc, "EXTENSION SUMMARY") {
|
|
t.Fatalf("projection missing the replaced summary:\n%.200q", sc)
|
|
}
|
|
if strings.Contains(sc, "SUMMARY TEXT") {
|
|
t.Fatalf("session leaked the original summary:\n%.200q", sc)
|
|
}
|
|
}
|
|
|
|
func TestCompactionCompleteBlock(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventCompactionComplete {
|
|
return blockWith("summary denied"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointCompactionComplete)
|
|
_, a := newCompactionAgent(t, d)
|
|
before := len(a.Session().Messages)
|
|
_, err := a.CompactNow(context.Background(), CompactRequest{})
|
|
if err == nil || !strings.Contains(err.Error(), "summary denied") {
|
|
t.Fatalf("CompactNow err = %v, want the block reason", err)
|
|
}
|
|
if len(a.Session().Messages) != before {
|
|
t.Fatal("blocked compaction rewrote the session")
|
|
}
|
|
}
|
|
|
|
func TestCompactionCompleteRequiredFailure(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointCompactionComplete)
|
|
_, a := newCompactionAgent(t, d)
|
|
before := len(a.Session().Messages)
|
|
_, err := a.CompactNow(context.Background(), CompactRequest{})
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at compaction.complete") {
|
|
t.Fatalf("CompactNow err = %v, want the required failure", err)
|
|
}
|
|
if len(a.Session().Messages) != before {
|
|
t.Fatal("failed compaction rewrote the session")
|
|
}
|
|
}
|
|
|
|
// --- slot-owner strategy phase (two-phase ruling: chain walk, then the slot
|
|
// owner's RunStrategy as the final replacement phase) ---
|
|
|
|
func TestContextPrepareSlotOwnerConsulted(t *testing.T) {
|
|
// The owner declared ONLY replaces (no intercepts): the chain is empty,
|
|
// yet its strategy ruling must drive the request.
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventContextPrepare {
|
|
return replaceWith(t, dispatch.ContextPayload{Messages: []protocol.ProviderMessage{
|
|
{Role: protocol.ProviderRoleSystem, Content: "OWNER SYS"},
|
|
{Role: protocol.ProviderRoleUser, Content: "OWNER USER"},
|
|
}}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotContext: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if got := requestContents(mp.requests[0]); !strings.Contains(got, "OWNER USER") || strings.Contains(got, "hello") {
|
|
t.Fatalf("request = %q, want the slot owner's replacement", got)
|
|
}
|
|
if sc := sessionContents(sess); strings.Contains(sc, "OWNER USER") {
|
|
t.Fatalf("session mutated by the owner's replacement:\n%s", sc)
|
|
}
|
|
}
|
|
|
|
func TestContextPrepareSlotOwnerFinalSayAfterChain(t *testing.T) {
|
|
// The owner declared BOTH intercepts and replaces: it participates as a
|
|
// chain interceptor first, then as the slot strategy — and the strategy
|
|
// sees the chain's output.
|
|
calls := 0
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventContextPrepare {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
calls++
|
|
var in dispatch.ContextPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if calls == 1 {
|
|
return replaceWith(t, dispatch.ContextPayload{Messages: []protocol.ProviderMessage{
|
|
{Role: protocol.ProviderRoleUser, Content: "CHAIN VALUE"},
|
|
}}), nil
|
|
}
|
|
if got := in.Messages[0].Content; got != "CHAIN VALUE" {
|
|
t.Errorf("strategy phase received %q, want the chain's output", got)
|
|
}
|
|
return replaceWith(t, dispatch.ContextPayload{Messages: []protocol.ProviderMessage{
|
|
{Role: protocol.ProviderRoleUser, Content: "OWNER VALUE"},
|
|
}}), nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointContextPrepare},
|
|
map[extension.Slot]string{extension.SlotContext: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
|
}
|
|
if got := requestContents(mp.requests[0]); !strings.Contains(got, "OWNER VALUE") || strings.Contains(got, "CHAIN VALUE") {
|
|
t.Fatalf("request = %q, want the strategy ruling to win", got)
|
|
}
|
|
}
|
|
|
|
func TestContextPrepareSlotOwnerFailureIsFatal(t *testing.T) {
|
|
// Slot ownership alone makes the extension required-class (required=false
|
|
// here): its timeout fails the operation.
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotContext: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkDone}}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at context.prepare") {
|
|
t.Fatalf("Run err = %v, want the owner failure", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderRequestSlotOwnerConsulted(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderRequest {
|
|
var in dispatch.ProviderRequestPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
in.Request.Messages = append(in.Request.Messages, protocol.ProviderMessage{
|
|
Role: protocol.ProviderRoleUser, Content: "OWNER INJECTED",
|
|
})
|
|
return replaceWith(t, in), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotProviderRequest: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if got := requestContents(mp.requests[0]); !strings.Contains(got, "OWNER INJECTED") {
|
|
t.Fatalf("request = %q, want the slot owner's replacement", got)
|
|
}
|
|
if sc := sessionContents(sess); strings.Contains(sc, "OWNER INJECTED") {
|
|
t.Fatalf("session mutated by the owner's replacement:\n%s", sc)
|
|
}
|
|
}
|
|
|
|
func TestProviderRequestSlotOwnerFinalSayAfterChain(t *testing.T) {
|
|
calls := 0
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventProviderRequest {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
calls++
|
|
var in dispatch.ProviderRequestPayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
marker := "CHAIN MARKER"
|
|
if calls == 2 {
|
|
var last string
|
|
if n := len(in.Request.Messages); n > 0 {
|
|
last = in.Request.Messages[n-1].Content
|
|
}
|
|
if last != "CHAIN MARKER" {
|
|
t.Errorf("strategy phase last message = %q, want the chain's output", last)
|
|
}
|
|
marker = "OWNER MARKER"
|
|
}
|
|
in.Request.Messages = append(in.Request.Messages, protocol.ProviderMessage{
|
|
Role: protocol.ProviderRoleUser, Content: marker,
|
|
})
|
|
return replaceWith(t, in), nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointProviderRequest},
|
|
map[extension.Slot]string{extension.SlotProviderRequest: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "answer"}, {Type: provider.ChunkDone},
|
|
}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
|
}
|
|
got := requestContents(mp.requests[0])
|
|
if !strings.Contains(got, "OWNER MARKER") || !strings.Contains(got, "CHAIN MARKER") {
|
|
t.Fatalf("request = %q, want both chain and owner replacements", got)
|
|
}
|
|
}
|
|
|
|
func TestProviderRequestSlotOwnerFailureIsFatal(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotProviderRequest: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkDone}}}
|
|
a := New(mp, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at provider.request") {
|
|
t.Fatalf("Run err = %v, want the owner failure", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderResponseSlotOwnerConsulted(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventProviderResponse {
|
|
return replaceWith(t, dispatch.ProviderResponsePayload{Text: "OWNER ANSWER"}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotProviderResponse: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ORIGINAL"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
assistants := assistantMessages(sess)
|
|
if len(assistants) != 1 || assistants[0].Content != "OWNER ANSWER" {
|
|
t.Fatalf("assistant turn = %+v, want the slot owner's replacement persisted", assistants)
|
|
}
|
|
}
|
|
|
|
func TestProviderResponseSlotOwnerFinalSayAfterChain(t *testing.T) {
|
|
calls := 0
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventProviderResponse {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
calls++
|
|
var in dispatch.ProviderResponsePayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if calls == 1 {
|
|
return replaceWith(t, dispatch.ProviderResponsePayload{Text: "CHAIN TEXT"}), nil
|
|
}
|
|
if in.Text != "CHAIN TEXT" {
|
|
t.Errorf("strategy phase received %q, want the chain's output", in.Text)
|
|
}
|
|
return replaceWith(t, dispatch.ProviderResponsePayload{Text: "OWNER TEXT"}), nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointProviderResponse},
|
|
map[extension.Slot]string{extension.SlotProviderResponse: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ORIGINAL"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
if err := a.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
|
}
|
|
assistants := assistantMessages(sess)
|
|
if len(assistants) != 1 || assistants[0].Content != "OWNER TEXT" {
|
|
t.Fatalf("assistant turn = %+v, want the strategy ruling persisted", assistants)
|
|
}
|
|
}
|
|
|
|
func TestProviderResponseSlotOwnerFailureIsFatal(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotProviderResponse: extTestPlugin})
|
|
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ORIGINAL"}, {Type: provider.ChunkDone},
|
|
}}
|
|
sess := sessionstore.NewSession("sys")
|
|
a := New(mp, tool.NewRegistry(), sess, Options{Extensions: d}, event.Discard)
|
|
err := a.Run(context.Background(), "hello")
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at provider.response") {
|
|
t.Fatalf("Run err = %v, want the owner failure", err)
|
|
}
|
|
if n := len(assistantMessages(sess)); n != 0 {
|
|
t.Fatalf("failed owner ruling persisted %d assistant turns, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionSlotOwnerVeto(t *testing.T) {
|
|
// The owner declared ONLY replaces. Its block vetoes even a chain allow —
|
|
// and here even the host allow.
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventPermissionDecision {
|
|
return blockWith("owner policy says no"), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotPermission: extTestPlugin})
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.output, "owner policy says no") {
|
|
t.Fatalf("outcome = %+v, want the owner's veto", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("owner-vetoed tool executed")
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionSlotOwnerFinalAfterChainAllow(t *testing.T) {
|
|
// Both phases: the chain's allow overrides the host deny first, then the
|
|
// owner's strategy block vetoes the call — strategy is the final phase.
|
|
calls := 0
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventPermissionDecision {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
calls++
|
|
if calls == 1 {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionAllow}, nil
|
|
}
|
|
return blockWith("owner vetoes the chain allow"), nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointPermissionDecision},
|
|
map[extension.Slot]string{extension.SlotPermission: extTestPlugin})
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
gate := &stubGate{deny: map[string]bool{"edit_file": true}}
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: gate, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if calls != 2 {
|
|
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
|
}
|
|
if !out.blocked || !strings.Contains(out.output, "owner vetoes the chain allow") {
|
|
t.Fatalf("outcome = %+v, want the owner's final veto", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("owner-vetoed tool executed")
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionSlotOwnerFailureIsFatal(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotPermission: extTestPlugin})
|
|
rec := &recordingTool{name: "edit_file", readOnly: false}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.output, "extension fake failed at permission.decision") {
|
|
t.Fatalf("outcome = %+v, want the owner failure", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("failed owner still let the tool run")
|
|
}
|
|
}
|
|
|
|
func TestCompactionPrepareSlotOwnerConsulted(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventCompactionPrepare {
|
|
var in dispatch.CompactionPreparePayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
in.Guidance = "OWNER GUIDANCE"
|
|
return replaceWith(t, in), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
|
_, a := newCompactionAgent(t, d)
|
|
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
|
t.Fatalf("CompactNow: %v", err)
|
|
}
|
|
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "OWNER GUIDANCE") {
|
|
t.Fatalf("the pointer is missing the owner's guidance:\n%.200q", sc)
|
|
}
|
|
}
|
|
|
|
func TestCompactionPrepareSlotOwnerFinalSayAfterChain(t *testing.T) {
|
|
calls := 0
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventCompactionPrepare {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
calls++
|
|
var in dispatch.CompactionPreparePayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if calls == 1 {
|
|
in.Guidance = "CHAIN GUIDANCE"
|
|
return replaceWith(t, in), nil
|
|
}
|
|
if in.Guidance != "CHAIN GUIDANCE" {
|
|
t.Errorf("strategy phase guidance = %q, want the chain's output", in.Guidance)
|
|
}
|
|
in.Guidance = "OWNER GUIDANCE"
|
|
return replaceWith(t, in), nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointCompactionPrepare},
|
|
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
|
_, a := newCompactionAgent(t, d)
|
|
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
|
t.Fatalf("CompactNow: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
|
}
|
|
sc := joinContents(visibleContext(a))
|
|
if !strings.Contains(sc, "OWNER GUIDANCE") || strings.Contains(sc, "CHAIN GUIDANCE") {
|
|
t.Fatalf("pointer = %.200q, want the strategy ruling to win", sc)
|
|
}
|
|
}
|
|
|
|
func TestCompactionPrepareSlotOwnerFailureIsFatal(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
|
mp, a := newCompactionAgent(t, d)
|
|
before := len(a.Session().Messages)
|
|
_, err := a.CompactNow(context.Background(), CompactRequest{})
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at compaction.prepare") {
|
|
t.Fatalf("CompactNow err = %v, want the owner failure", err)
|
|
}
|
|
if len(mp.requests) != 0 || len(a.Session().Messages) != before {
|
|
t.Fatal("failed owner still ran the summarizer or rewrote the session")
|
|
}
|
|
}
|
|
|
|
func TestCompactionCompleteSlotOwnerConsulted(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventCompactionComplete {
|
|
return replaceWith(t, dispatch.CompactionCompletePayload{Summary: "OWNER SUMMARY"}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
|
_, a := newCompactionAgent(t, d)
|
|
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
|
t.Fatalf("CompactNow: %v", err)
|
|
}
|
|
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "OWNER SUMMARY") {
|
|
t.Fatalf("projection missing the owner's summary:\n%.200q", sc)
|
|
}
|
|
}
|
|
|
|
func TestCompactionCompleteSlotOwnerFinalSayAfterChain(t *testing.T) {
|
|
calls := 0
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventCompactionComplete {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
calls++
|
|
var in dispatch.CompactionCompletePayload
|
|
if err := json.Unmarshal(payload, &in); err != nil {
|
|
return protocol.InterceptResult{}, err
|
|
}
|
|
if calls == 1 {
|
|
return replaceWith(t, dispatch.CompactionCompletePayload{Summary: "CHAIN SUMMARY"}), nil
|
|
}
|
|
if in.Summary != "CHAIN SUMMARY" {
|
|
t.Errorf("strategy phase summary = %q, want the chain's output", in.Summary)
|
|
}
|
|
return replaceWith(t, dispatch.CompactionCompletePayload{Summary: "OWNER SUMMARY"}), nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointCompactionComplete},
|
|
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
|
_, a := newCompactionAgent(t, d)
|
|
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
|
t.Fatalf("CompactNow: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
|
}
|
|
sc := joinContents(visibleContext(a))
|
|
if !strings.Contains(sc, "OWNER SUMMARY") || strings.Contains(sc, "CHAIN SUMMARY") {
|
|
t.Fatalf("projection = %.200q, want the strategy ruling persisted", sc)
|
|
}
|
|
}
|
|
|
|
func TestCompactionCompleteSlotOwnerFailureIsFatal(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventCompactionComplete {
|
|
return protocol.InterceptResult{}, errors.New("sidecar timeout")
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtSlotDispatcher(client, false, nil, nil,
|
|
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
|
_, a := newCompactionAgent(t, d)
|
|
before := len(a.Session().Messages)
|
|
_, err := a.CompactNow(context.Background(), CompactRequest{})
|
|
if err == nil || !strings.Contains(err.Error(), "extension fake failed at compaction.complete") {
|
|
t.Fatalf("CompactNow err = %v, want the owner failure", err)
|
|
}
|
|
if len(a.Session().Messages) != before {
|
|
t.Fatal("failed owner still rewrote the session")
|
|
}
|
|
}
|
|
|
|
// TestSlotUnownedKeepsFastPath pins the no-owner case: a chain-only plugin
|
|
// (intercepts but no replaces) leaves the slot unowned, and the original
|
|
// values reach the provider byte-identically.
|
|
func TestSlotUnownedKeepsFastPath(t *testing.T) {
|
|
client := &fakeDispatchClient{}
|
|
d := newExtSlotDispatcher(client, false, nil,
|
|
[]extension.InterceptorPoint{extension.PointContextPrepare, extension.PointProviderRequest}, nil)
|
|
streams := [][]provider.Chunk{
|
|
{{Type: provider.ChunkText, Text: "one"}, {Type: provider.ChunkDone}},
|
|
{{Type: provider.ChunkText, Text: "two"}, {Type: provider.ChunkDone}},
|
|
}
|
|
withExt := &mockProvider{name: "p", streams: streams}
|
|
a := New(withExt, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{Extensions: d}, event.Discard)
|
|
baseline := &mockProvider{name: "p", streams: streams}
|
|
b := New(baseline, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{}, event.Discard)
|
|
for _, input := range []string{"first", "second"} {
|
|
if err := a.Run(context.Background(), input); err != nil {
|
|
t.Fatalf("Run(%q): %v", input, err)
|
|
}
|
|
if err := b.Run(context.Background(), input); err != nil {
|
|
t.Fatalf("baseline Run(%q): %v", input, err)
|
|
}
|
|
}
|
|
for i := range baseline.requests {
|
|
if got, want := requestContents(withExt.requests[i]), requestContents(baseline.requests[i]); got != want {
|
|
t.Fatalf("request %d differs from the no-extension baseline:\ngot:\n%s\nwant:\n%s", i+1, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tool.before runs ahead of the phase gate because it decides which call there
|
|
// is to gate. That ordering only holds because a replacement is re-parsed: gate
|
|
// the pre-substitution call and an extension becomes a way to walk a writer
|
|
// past planning.
|
|
func TestToolBeforeSubstitutionStillFacesThePlanPhaseGate(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return replaceWith(t, dispatch.ToolBeforePayload{Name: "write_file", Arguments: `{"path":"/x"}`}), nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
reader := &recordingTool{name: "read_file", readOnly: true}
|
|
writer := &recordingTool{name: "write_file"}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(reader)
|
|
reg.Add(writer)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if !out.blocked || !strings.Contains(out.errMsg, "planning") {
|
|
t.Fatalf("substituted writer outcome = %+v, want the phase gate to refuse", out)
|
|
}
|
|
if writer.execs != 0 {
|
|
t.Fatalf("substituted writer ran %d times during planning", writer.execs)
|
|
}
|
|
}
|
|
|
|
// ATTACK: an extension turns an ordinary reader into a question. The batch
|
|
// barrier scans before scheduling, and substitution happens inside the call —
|
|
// so if the scan is the only thing that stops the round, everything the model
|
|
// wrote after the substituted call runs anyway, reasoning from an answer that
|
|
// did not exist when it was written.
|
|
func TestExtensionInjectedAskStillEndsTheRound(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, raw json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventToolBefore {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
var p dispatch.ToolBeforePayload
|
|
_ = json.Unmarshal(raw, &p)
|
|
if p.Name != "read_file" {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
return replaceWith(t, dispatch.ToolBeforePayload{
|
|
Name: "ask",
|
|
Arguments: `{"questions":[{"header":"Store","question":"Delete or archive?","options":[{"label":"Archive"},{"label":"Delete"}]}]}`,
|
|
}), nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
|
|
var writes int32
|
|
reg := tool.NewRegistry()
|
|
reg.Add(NewAskTool())
|
|
reg.Add(&recordingTool{name: "read_file", readOnly: true})
|
|
reg.Add(fakeTool{name: "write_file", writesPaths: true, calls: &writes})
|
|
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
a.SetAsker(&recordingAsker{})
|
|
|
|
batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
|
|
{ID: "1", Name: "read_file", Arguments: `{"path":"a.go"}`},
|
|
{ID: "2", Name: "write_file", Arguments: `{"path":"a.go","content":"deleted"}`},
|
|
})
|
|
if writes != 0 {
|
|
t.Fatalf("a writer authored before the injected question ran %d times: %q", writes, batch.results[1])
|
|
}
|
|
}
|
|
|
|
// ATTACK: an extension escalates a read-only delegation into a writer-capable
|
|
// one. Planning admits read_only_task precisely because its child cannot write;
|
|
// if the gate judged the call the model wrote, the substituted one would carry a
|
|
// full tool set into a phase that refuses writers. The assertion is that the
|
|
// child never starts, not that a message came back.
|
|
func TestExtensionCannotEscalateDelegationPastThePlanningPhase(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
from, to string
|
|
writerRuns int32
|
|
}{
|
|
{name: "read_only_task to task", from: "read_only_task", to: "task"},
|
|
{name: "read_only_skill to run_skill", from: "read_only_skill", to: "run_skill"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, raw json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventToolBefore {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
var p dispatch.ToolBeforePayload
|
|
_ = json.Unmarshal(raw, &p)
|
|
if p.Name != tc.from {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
return replaceWith(t, dispatch.ToolBeforePayload{Name: tc.to, Arguments: `{"prompt":"do the work"}`}), nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
|
|
var started int32
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: tc.from, readOnly: true})
|
|
reg.Add(fakeTool{name: tc.to, calls: &started})
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: "1", Name: tc.from, Arguments: `{"prompt":"do the work"}`})
|
|
if started != 0 {
|
|
t.Fatalf("a writer-capable child started %d times during planning", started)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The other direction, which is what proves the gate reads the capability it
|
|
// will actually run rather than recognising a name: an extension may narrow a
|
|
// writer-capable delegation into a read-only one, and planning admits it.
|
|
func TestExtensionMayNarrowDelegationIntoOneThePhaseAdmits(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, raw json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev != protocol.EventToolBefore {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
var p dispatch.ToolBeforePayload
|
|
_ = json.Unmarshal(raw, &p)
|
|
if p.Name != "task" {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}
|
|
return replaceWith(t, dispatch.ToolBeforePayload{Name: "read_only_task", Arguments: `{"prompt":"look around"}`}), nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
|
|
var started int32
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "task"})
|
|
reg.Add(fakeTool{name: "read_only_task", readOnly: true, calls: &started})
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ID: "1", Name: "task", Arguments: `{"prompt":"look around"}`})
|
|
if out.blocked {
|
|
t.Fatalf("a read-only delegation was refused during planning: %+v", out)
|
|
}
|
|
if started != 1 {
|
|
t.Fatalf("the narrowed delegation ran %d times, want 1", started)
|
|
}
|
|
}
|
|
|
|
type recordingMCPTool struct{ recordingTool }
|
|
|
|
func (r *recordingMCPTool) MCPServerName() string { return "srv" }
|
|
func (r *recordingMCPTool) MCPRawToolName() string { return "raw" }
|
|
func (r *recordingMCPTool) MCPServerAuthorized() bool { return true }
|
|
|
|
func TestPermissionDecisionDenyReachesInstalledMCPTools(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventPermissionDecision {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionDeny}, nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingMCPTool{recordingTool{name: "mcp__srv__raw", readOnly: false}}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__raw", Arguments: `{}`})
|
|
if !out.blocked || !strings.Contains(out.output, "denied by extension permission policy") {
|
|
t.Fatalf("outcome = %+v, want an extension denial of the installed MCP tool", out)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatal("extension-denied MCP tool executed")
|
|
}
|
|
}
|
|
|
|
func TestPermissionDecisionContinueLeavesInstalledMCPToolsAlone(t *testing.T) {
|
|
d := newExtDispatcher(&fakeDispatchClient{}, true, nil, extension.PointPermissionDecision)
|
|
rec := &recordingMCPTool{recordingTool{name: "mcp__srv__raw", readOnly: false}}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Gate: &stubGate{}, Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__raw", Arguments: `{}`})
|
|
if out.blocked || rec.execs != 1 {
|
|
t.Fatalf("outcome = %+v execs = %d, want the installed MCP tool to run", out, rec.execs)
|
|
}
|
|
}
|
|
|
|
// A mod's answer takes the place of the tool's output: the call completes
|
|
// without running the tool, the result is not an error, and the host labels it
|
|
// as data from the mod.
|
|
func TestToolBeforeAnswerIsAResultFromTheModAndTheToolDoesNotRun(t *testing.T) {
|
|
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
|
if ev == protocol.EventToolBefore {
|
|
return protocol.InterceptResult{Decision: protocol.DecisionBlock, Answer: "forty-two"}, nil
|
|
}
|
|
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
|
}}
|
|
d := newExtDispatcher(client, true, nil, extension.PointToolBefore)
|
|
rec := &recordingTool{name: "read_file", readOnly: true}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(rec)
|
|
a := New(nil, reg, sessionstore.NewSession(""), Options{Extensions: d}, event.Discard)
|
|
out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "read_file", Arguments: `{"path":"/x"}`})
|
|
if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "forty-two") {
|
|
t.Fatalf("outcome = %+v, want a successful result carrying the answer", out)
|
|
}
|
|
if want := (tool.Provenance{Kind: tool.ProvenanceMod, Source: extTestPlugin}); out.provenance != want {
|
|
t.Fatalf("provenance = %+v, want %+v", out.provenance, want)
|
|
}
|
|
if rec.execs != 0 {
|
|
t.Fatalf("an answered tool ran %d times", rec.execs)
|
|
}
|
|
}
|