picoclaw/pkg/hooks/hooks_test.go

657 lines
17 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package hooks
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
)
func TestNewHookRegistry(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
// Triggering all hooks on an empty registry should not panic.
r.TriggerMessageReceived(ctx, &MessageReceivedEvent{Content: "hello"})
r.TriggerMessageSending(ctx, &MessageSendingEvent{Content: "hello"})
r.TriggerBeforeToolCall(ctx, &BeforeToolCallEvent{ToolName: "t"})
r.TriggerAfterToolCall(ctx, &AfterToolCallEvent{ToolName: "t"})
r.TriggerLLMInput(ctx, &LLMInputEvent{AgentID: "a"})
r.TriggerLLMOutput(ctx, &LLMOutputEvent{AgentID: "a"})
r.TriggerSessionStart(ctx, &SessionEvent{AgentID: "a"})
r.TriggerSessionEnd(ctx, &SessionEvent{AgentID: "a"})
}
func TestVoidHookExecution(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var called atomic.Bool
r.OnMessageReceived("test", 0, func(_ context.Context, e *MessageReceivedEvent) error {
called.Store(true)
if e.Content != "ping" {
t.Errorf("Expected content 'ping', got '%s'", e.Content)
}
return nil
})
r.TriggerMessageReceived(ctx, &MessageReceivedEvent{Content: "ping"})
if !called.Load() {
t.Error("Expected handler to be called")
}
}
func TestVoidHooksConcurrent(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var count atomic.Int32
started := make(chan struct{}, 5)
release := make(chan struct{})
done := make(chan struct{})
for i := range 5 {
r.OnMessageReceived("hook-"+string(rune('A'+i)), i, func(_ context.Context, _ *MessageReceivedEvent) error {
started <- struct{}{}
<-release
count.Add(1)
return nil
})
}
go func() {
r.TriggerMessageReceived(ctx, &MessageReceivedEvent{Content: "test"})
close(done)
}()
// All 5 handlers must reach the barrier concurrently.
for i := range 5 {
select {
case <-started:
case <-time.After(1 * time.Second):
t.Fatalf("timeout waiting for handler %d to start", i+1)
}
}
// Release all handlers.
close(release)
select {
case <-done:
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for handlers to complete")
}
if count.Load() != 5 {
t.Errorf("Expected 5 handlers called, got %d", count.Load())
}
}
func TestVoidHooksReceiveIsolatedMessageReceivedEvents(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
r.OnMessageReceived("mutator-a", 0, func(_ context.Context, e *MessageReceivedEvent) error {
e.Content = "changed-a"
e.Media[0] = "changed-media-a"
e.Metadata["k"] = "changed-a"
e.Metadata["new-a"] = "x"
return nil
})
r.OnMessageReceived("mutator-b", 1, func(_ context.Context, e *MessageReceivedEvent) error {
e.Content = "changed-b"
e.Media = append(e.Media, "extra")
e.Metadata["k"] = "changed-b"
e.Metadata["new-b"] = "y"
return nil
})
event := &MessageReceivedEvent{
Content: "original",
Media: []string{"m1"},
Metadata: map[string]string{"k": "v"},
}
r.TriggerMessageReceived(ctx, event)
if event.Content != "original" {
t.Fatalf("expected original content to remain unchanged, got %q", event.Content)
}
if len(event.Media) != 1 || event.Media[0] != "m1" {
t.Fatalf("expected original media to remain unchanged, got %#v", event.Media)
}
if got := event.Metadata["k"]; got != "v" {
t.Fatalf("expected metadata[k] to remain v, got %q", got)
}
if _, ok := event.Metadata["new-a"]; ok {
t.Fatal("unexpected mutation leaked from hook mutator-a")
}
if _, ok := event.Metadata["new-b"]; ok {
t.Fatal("unexpected mutation leaked from hook mutator-b")
}
}
func TestVoidHooksReceiveIsolatedAfterToolCallEvents(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
r.OnAfterToolCall("mutator-a", 0, func(_ context.Context, e *AfterToolCallEvent) error {
e.Args["k"] = "changed-a"
e.Result.ForLLM = "mutated-a"
return nil
})
r.OnAfterToolCall("mutator-b", 1, func(_ context.Context, e *AfterToolCallEvent) error {
e.Args["k"] = "changed-b"
e.Args["new"] = "v"
e.Result.ForUser = "mutated-b"
return nil
})
event := &AfterToolCallEvent{
ToolName: "shell",
Args: map[string]any{"k": "original"},
Result: &tools.ToolResult{
ForLLM: "for-llm",
ForUser: "for-user",
},
}
// Use a local copy so we can compare immutable expectations.
r.TriggerAfterToolCall(ctx, event)
if got := event.Args["k"]; got != "original" {
t.Fatalf("expected args[k] to remain original, got %#v", got)
}
if _, ok := event.Args["new"]; ok {
t.Fatal("unexpected args mutation leaked from hook")
}
if event.Result.ForLLM != "for-llm" {
t.Fatalf("expected original result.ForLLM to remain unchanged, got %q", event.Result.ForLLM)
}
if event.Result.ForUser != "for-user" {
t.Fatalf("expected original result.ForUser to remain unchanged, got %q", event.Result.ForUser)
}
}
func TestVoidHooksReceiveIsolatedLLMInputToolSchema(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
r.OnLLMInput("mutator", 0, func(_ context.Context, e *LLMInputEvent) error {
required, ok := e.Tools[0].Function.Parameters["required"].([]string)
if !ok {
t.Fatal("required should be []string")
}
required[0] = "mutated"
e.Tools[0].Function.Parameters["required"] = append(required, "extra")
return nil
})
event := &LLMInputEvent{
AgentID: "a1",
Model: "m1",
Tools: []providers.ToolDefinition{
{
Type: "function",
Function: providers.ToolFunctionDefinition{
Name: "message",
Parameters: map[string]any{
"type": "object",
"required": []string{"content"},
},
},
},
},
}
r.TriggerLLMInput(ctx, event)
required, ok := event.Tools[0].Function.Parameters["required"].([]string)
if !ok {
t.Fatal("required should remain []string")
}
if len(required) != 1 || required[0] != "content" {
t.Fatalf("expected required to remain unchanged, got %#v", required)
}
}
func TestVoidHooksReceiveIsolatedStructValuesInMap(t *testing.T) {
type schemaSpec struct {
Required []string
Meta map[string]string
}
r := NewHookRegistry()
ctx := context.Background()
r.OnLLMInput("struct-mutator", 0, func(_ context.Context, e *LLMInputEvent) error {
spec, ok := e.Tools[0].Function.Parameters["schema"].(schemaSpec)
if !ok {
t.Fatal("schema should be schemaSpec")
}
spec.Required[0] = "mutated"
spec.Meta["k"] = "changed"
e.Tools[0].Function.Parameters["schema"] = spec
return nil
})
event := &LLMInputEvent{
AgentID: "a1",
Model: "m1",
Tools: []providers.ToolDefinition{
{
Type: "function",
Function: providers.ToolFunctionDefinition{
Name: "message",
Parameters: map[string]any{
"schema": schemaSpec{
Required: []string{"content"},
Meta: map[string]string{"k": "v"},
},
},
},
},
},
}
r.TriggerLLMInput(ctx, event)
spec, ok := event.Tools[0].Function.Parameters["schema"].(schemaSpec)
if !ok {
t.Fatal("schema should remain schemaSpec")
}
if len(spec.Required) != 1 || spec.Required[0] != "content" {
t.Fatalf("expected required to remain unchanged, got %#v", spec.Required)
}
if got := spec.Meta["k"]; got != "v" {
t.Fatalf("expected meta[k] to remain v, got %q", got)
}
}
func TestVoidHooksFailOpenOnSlowHandler(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
r.OnLLMInput("slow", 0, func(_ context.Context, _ *LLMInputEvent) error {
close(started)
<-release
close(done)
return nil
})
begin := time.Now()
r.TriggerLLMInput(ctx, &LLMInputEvent{AgentID: "a1"})
elapsed := time.Since(begin)
if elapsed > voidHookWaitBudget*3 {
t.Fatalf("expected fail-open dispatch within budget, got %s", elapsed)
}
select {
case <-started:
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for slow handler to start")
}
close(release)
select {
case <-done:
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for slow handler to finish after release")
}
}
func TestModifyingHookPriority(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var mu sync.Mutex
var order []string
// Register in reverse priority order to verify sorting.
r.OnMessageSending("third", 30, func(_ context.Context, _ *MessageSendingEvent) error {
mu.Lock()
order = append(order, "third")
mu.Unlock()
return nil
})
r.OnMessageSending("first", 10, func(_ context.Context, _ *MessageSendingEvent) error {
mu.Lock()
order = append(order, "first")
mu.Unlock()
return nil
})
r.OnMessageSending("second", 20, func(_ context.Context, _ *MessageSendingEvent) error {
mu.Lock()
order = append(order, "second")
mu.Unlock()
return nil
})
r.TriggerMessageSending(ctx, &MessageSendingEvent{Content: "hi"})
if len(order) != 3 {
t.Fatalf("Expected 3 handlers, got %d", len(order))
}
if order[0] != "first" || order[1] != "second" || order[2] != "third" {
t.Errorf("Expected [first second third], got %v", order)
}
}
func TestModifyingHookCancel(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var secondCalled bool
r.OnMessageSending("canceler", 10, func(_ context.Context, e *MessageSendingEvent) error {
e.Cancel = true
e.CancelReason = "blocked"
return nil
})
r.OnMessageSending("after-cancel", 20, func(_ context.Context, _ *MessageSendingEvent) error {
secondCalled = true
return nil
})
event := &MessageSendingEvent{Content: "hi"}
r.TriggerMessageSending(ctx, event)
if !event.Cancel {
t.Error("Expected Cancel to be true")
}
if secondCalled {
t.Error("Expected second handler NOT to be called after cancel")
}
}
func TestBeforeToolCallModification(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
r.OnBeforeToolCall("modifier", 10, func(_ context.Context, e *BeforeToolCallEvent) error {
e.Args["injected"] = "value"
return nil
})
event := &BeforeToolCallEvent{
ToolName: "search",
Args: map[string]any{"query": "test"},
}
r.TriggerBeforeToolCall(ctx, event)
if event.Args["injected"] != "value" {
t.Error("Expected injected arg to persist")
}
if event.Args["query"] != "test" {
t.Error("Expected original arg to remain")
}
}
func TestMessageSendingFilter(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
r.OnMessageSending("rewriter", 10, func(_ context.Context, e *MessageSendingEvent) error {
e.Content = "[filtered] " + e.Content
return nil
})
event := &MessageSendingEvent{Content: "hello world"}
r.TriggerMessageSending(ctx, event)
if event.Content != "[filtered] hello world" {
t.Errorf("Expected '[filtered] hello world', got '%s'", event.Content)
}
}
func TestZeroCostWhenEmpty(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
// This is primarily a safety/smoke test — no panics, no allocations of note.
for range 100 {
r.TriggerMessageReceived(ctx, &MessageReceivedEvent{})
r.TriggerMessageSending(ctx, &MessageSendingEvent{})
r.TriggerBeforeToolCall(ctx, &BeforeToolCallEvent{})
r.TriggerAfterToolCall(ctx, &AfterToolCallEvent{})
r.TriggerLLMInput(ctx, &LLMInputEvent{})
r.TriggerLLMOutput(ctx, &LLMOutputEvent{})
r.TriggerSessionStart(ctx, &SessionEvent{})
r.TriggerSessionEnd(ctx, &SessionEvent{})
}
}
func TestLLMInputOutput(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var inputCalled, outputCalled atomic.Bool
r.OnLLMInput("input-hook", 0, func(_ context.Context, e *LLMInputEvent) error {
if e.Model != "gpt-4" {
t.Errorf("Expected model 'gpt-4', got '%s'", e.Model)
}
inputCalled.Store(true)
return nil
})
r.OnLLMOutput("output-hook", 0, func(_ context.Context, e *LLMOutputEvent) error {
if e.Content != "response" {
t.Errorf("Expected content 'response', got '%s'", e.Content)
}
outputCalled.Store(true)
return nil
})
r.TriggerLLMInput(ctx, &LLMInputEvent{AgentID: "a1", Model: "gpt-4", Iteration: 1})
r.TriggerLLMOutput(ctx, &LLMOutputEvent{AgentID: "a1", Model: "gpt-4", Content: "response", Iteration: 1})
if !inputCalled.Load() {
t.Error("Expected LLM input hook to be called")
}
if !outputCalled.Load() {
t.Error("Expected LLM output hook to be called")
}
}
func TestSessionStartEnd(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var startCalled, endCalled atomic.Bool
r.OnSessionStart("start-hook", 0, func(_ context.Context, e *SessionEvent) error {
if e.SessionKey != "sess-1" {
t.Errorf("Expected session key 'sess-1', got '%s'", e.SessionKey)
}
startCalled.Store(true)
return nil
})
r.OnSessionEnd("end-hook", 0, func(_ context.Context, e *SessionEvent) error {
if e.SessionKey != "sess-1" {
t.Errorf("Expected session key 'sess-1', got '%s'", e.SessionKey)
}
endCalled.Store(true)
return nil
})
event := &SessionEvent{AgentID: "a1", SessionKey: "sess-1", Channel: "test", ChatID: "c1"}
r.TriggerSessionStart(ctx, event)
r.TriggerSessionEnd(ctx, event)
if !startCalled.Load() {
t.Error("Expected session start hook to be called")
}
if !endCalled.Load() {
t.Error("Expected session end hook to be called")
}
}
func TestConcurrentRegistrationAndTrigger(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var wg sync.WaitGroup
// Goroutines registering hooks.
for i := range 10 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
r.OnMessageReceived(
fmt.Sprintf("reg-hook-%d", idx),
idx,
func(_ context.Context, _ *MessageReceivedEvent) error {
return nil
},
)
}(i)
}
// Goroutines triggering hooks concurrently.
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
r.TriggerMessageReceived(ctx, &MessageReceivedEvent{Content: "race"})
}()
}
wg.Wait()
}
func TestInsertSorted(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var order []int
// Register with priorities: 50, 10, 30, 20, 40
priorities := []int{50, 10, 30, 20, 40}
for _, p := range priorities {
r.OnBeforeToolCall(fmt.Sprintf("p-%d", p), p, func(_ context.Context, _ *BeforeToolCallEvent) error {
order = append(order, p)
return nil
})
}
r.TriggerBeforeToolCall(ctx, &BeforeToolCallEvent{ToolName: "test", Args: map[string]any{}})
expected := []int{10, 20, 30, 40, 50}
if len(order) != len(expected) {
t.Fatalf("Expected %d handlers, got %d", len(expected), len(order))
}
for i, v := range expected {
if order[i] != v {
t.Errorf("Position %d: expected priority %d, got %d", i, v, order[i])
}
}
}
func TestAfterToolCallExecution(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
var called bool
var capturedName string
r.OnAfterToolCall("logger", 0, func(_ context.Context, event *AfterToolCallEvent) error {
called = true
capturedName = event.ToolName
return nil
})
r.TriggerAfterToolCall(ctx, &AfterToolCallEvent{
ToolName: "shell",
Args: map[string]any{"cmd": "ls"},
Channel: "telegram",
ChatID: "123",
})
if !called {
t.Error("Expected after_tool_call handler to be called")
}
if capturedName != "shell" {
t.Errorf("Expected ToolName 'shell', got '%s'", capturedName)
}
}
func TestHandlerErrorsSwallowed(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
// Test void hooks: error in one handler doesn't prevent others from running
var secondCalled bool
r.OnMessageReceived("erroring", 10, func(_ context.Context, _ *MessageReceivedEvent) error {
return fmt.Errorf("handler error")
})
r.OnMessageReceived("observer", 20, func(_ context.Context, _ *MessageReceivedEvent) error {
secondCalled = true
return nil
})
r.TriggerMessageReceived(ctx, &MessageReceivedEvent{Content: "test"})
if !secondCalled {
t.Error("Expected second void handler to run despite first handler's error")
}
// Test modifying hooks: error doesn't stop chain (only Cancel does)
var modifySecondCalled bool
r.OnMessageSending("erroring", 10, func(_ context.Context, _ *MessageSendingEvent) error {
return fmt.Errorf("handler error")
})
r.OnMessageSending("modifier", 20, func(_ context.Context, _ *MessageSendingEvent) error {
modifySecondCalled = true
return nil
})
r.TriggerMessageSending(ctx, &MessageSendingEvent{Content: "test"})
if !modifySecondCalled {
t.Error("Expected second modifying handler to run despite first handler's error")
}
}
func TestPanicRecovery(t *testing.T) {
r := NewHookRegistry()
ctx := context.Background()
// Void hook: panic in one handler shouldn't crash, other handlers should still run
var safeHandlerCalled bool
r.OnLLMInput("panicker", 10, func(_ context.Context, _ *LLMInputEvent) error {
panic("boom")
})
r.OnLLMInput("safe", 10, func(_ context.Context, _ *LLMInputEvent) error {
safeHandlerCalled = true
return nil
})
// Should not panic
r.TriggerLLMInput(ctx, &LLMInputEvent{AgentID: "test"})
if !safeHandlerCalled {
t.Error("Expected safe handler to run despite panicking sibling")
}
// Modifying hook: panic in handler shouldn't crash
r.OnBeforeToolCall("panicker", 10, func(_ context.Context, _ *BeforeToolCallEvent) error {
panic("boom")
})
// Should not panic
r.TriggerBeforeToolCall(ctx, &BeforeToolCallEvent{ToolName: "test"})
}