Files
kit/internal/extensions/wrapper_test.go
Ed Zynda 996b15c9b9 fix(extensions): return nil error for blocked/disabled tools so LLM sees the reason
Tool blocking via OnToolCall and SetActiveTools returned both a
ToolResponse (IsError=true) and a Go error. Fantasy treats a non-nil
Go error from tool.Run() as a critical failure, aborting the agent
loop without delivering the tool result to the LLM. The model never
saw the block reason and would retry or hallucinate.

- Return nil error for blocked tools (OnToolCall Block=true)
- Return nil error for disabled tools (SetActiveTools)
- Return nil error for extension tool execution failures
- Update tests to assert nil error (IsError response conveys the error)

Fixes #20
2026-04-23 13:13:28 +03:00

358 lines
10 KiB
Go

package extensions
import (
"context"
"testing"
"charm.land/fantasy"
)
// mockTool implements fantasy.AgentTool for testing.
type mockTool struct {
name string
runFn func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error)
provOpt fantasy.ProviderOptions
}
func (m *mockTool) Info() fantasy.ToolInfo {
return fantasy.ToolInfo{Name: m.name, Description: "mock tool"}
}
func (m *mockTool) ProviderOptions() fantasy.ProviderOptions { return m.provOpt }
func (m *mockTool) SetProviderOptions(o fantasy.ProviderOptions) { m.provOpt = o }
func (m *mockTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
if m.runFn != nil {
return m.runFn(ctx, call)
}
return fantasy.NewTextResponse("ok"), nil
}
func newMockTool(name string) *mockTool {
return &mockTool{name: name}
}
func TestWrapToolsWithExtensions_NilRunner(t *testing.T) {
tools := []fantasy.AgentTool{newMockTool("test")}
result := WrapToolsWithExtensions(tools, nil)
if len(result) != 1 {
t.Fatalf("expected 1 tool, got %d", len(result))
}
// Should be the same pointer (unwrapped).
if result[0] != tools[0] {
t.Error("expected original tool when runner is nil")
}
}
func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
r := makeRunner(makeHandlerExt("other.go", map[EventType][]HandlerFunc{
SessionStart: {func(e Event, c Context) Result { return nil }},
}))
tools := []fantasy.AgentTool{newMockTool("test")}
result := WrapToolsWithExtensions(tools, r)
// Tools are always wrapped now (for SetActiveTools support),
// but Info() should pass through correctly.
if result[0] == tools[0] {
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
}
if result[0].Info().Name != "test" {
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
}
}
func TestWrapToolsWithExtensions_WrapsWhenHandlersExist(t *testing.T) {
r := makeRunner(makeHandlerExt("tc.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result { return nil }},
}))
tools := []fantasy.AgentTool{newMockTool("test")}
result := WrapToolsWithExtensions(tools, r)
if result[0] == tools[0] {
t.Error("expected wrapped tool when ToolCall handlers exist")
}
// Verify Info() is passed through.
if result[0].Info().Name != "test" {
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
}
}
func TestWrappedTool_NormalExecution(t *testing.T) {
var toolCallSeen, toolResultSeen bool
r := makeRunner(makeHandlerExt("observe.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result {
toolCallSeen = true
return nil
}},
ToolResult: {func(e Event, c Context) Result {
toolResultSeen = true
return nil
}},
}))
mock := newMockTool("bash")
mock.runFn = func(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return fantasy.NewTextResponse("output"), nil
}
tools := WrapToolsWithExtensions([]fantasy.AgentTool{mock}, r)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "output" {
t.Errorf("expected 'output', got %q", resp.Content)
}
if !toolCallSeen {
t.Error("ToolCall handler was not invoked")
}
if !toolResultSeen {
t.Error("ToolResult handler was not invoked")
}
}
func TestWrappedTool_SourceField(t *testing.T) {
var gotSource string
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result {
gotSource = e.(ToolCallEvent).Source
return nil
}},
}))
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
if gotSource != "llm" {
t.Errorf("expected Source='llm', got %q", gotSource)
}
}
func TestWrappedTool_BlockExecution(t *testing.T) {
var toolRan bool
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result {
return ToolCallResult{Block: true, Reason: "forbidden"}
}},
}))
mock := newMockTool("danger")
mock.runFn = func(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
toolRan = true
return fantasy.NewTextResponse("bad"), nil
}
tools := WrapToolsWithExtensions([]fantasy.AgentTool{mock}, r)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1"})
if toolRan {
t.Error("tool should not have run after block")
}
if err != nil {
t.Error("expected nil error for blocked tool (error is conveyed via IsError response)")
}
if resp.IsError != true {
t.Error("expected IsError=true from blocked response")
}
}
func TestWrappedTool_ModifyResult(t *testing.T) {
modified := "redacted"
r := makeRunner(makeHandlerExt("redactor.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result { return nil }},
ToolResult: {func(e Event, c Context) Result {
return ToolResultResult{Content: &modified}
}},
}))
mock := newMockTool("read")
mock.runFn = func(_ context.Context, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
return fantasy.NewTextResponse("secret data"), nil
}
tools := WrapToolsWithExtensions([]fantasy.AgentTool{mock}, r)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "redacted" {
t.Errorf("expected 'redacted', got %q", resp.Content)
}
}
func TestWrappedTool_ExecutionStartEnd(t *testing.T) {
var startSeen, endSeen bool
r := makeRunner(makeHandlerExt("lifecycle.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result { return nil }},
ToolExecutionStart: {func(e Event, c Context) Result { startSeen = true; return nil }},
ToolExecutionEnd: {func(e Event, c Context) Result { endSeen = true; return nil }},
}))
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("test")}, r)
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1"})
if !startSeen {
t.Error("ToolExecutionStart not emitted")
}
if !endSeen {
t.Error("ToolExecutionEnd not emitted")
}
}
func TestExtensionToolsAsLLMTools(t *testing.T) {
defs := []ToolDef{
{
Name: "greet",
Description: "greets someone",
Parameters: `{"type":"object"}`,
Execute: func(input string) (string, error) { return "hello " + input, nil },
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
info := tools[0].Info()
if info.Name != "greet" {
t.Errorf("expected name 'greet', got %q", info.Name)
}
if info.Description != "greets someone" {
t.Errorf("expected description 'greets someone', got %q", info.Description)
}
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "world"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "hello world" {
t.Errorf("expected 'hello world', got %q", resp.Content)
}
}
func TestExtensionTool_Error(t *testing.T) {
defs := []ToolDef{
{
Name: "fail",
Execute: func(input string) (string, error) { return "", context.DeadlineExceeded },
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
if err != nil {
t.Error("expected nil error (error is conveyed via IsError response)")
}
if !resp.IsError {
t.Error("expected IsError=true")
}
}
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
var gotCancelled bool
var gotProgress []string
defs := []ToolDef{
{
Name: "rich",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
gotCancelled = tc.IsCancelled()
tc.OnProgress("step 1")
tc.OnProgress("step 2")
return "done: " + input, nil
},
},
}
// Without runner, OnProgress is a no-op.
tools := ExtensionToolsAsLLMTools(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "done: test" {
t.Errorf("expected 'done: test', got %q", resp.Content)
}
if gotCancelled {
t.Error("expected IsCancelled=false for non-cancelled context")
}
// With runner, OnProgress routes through Print.
runner := NewRunner(nil)
runner.SetContext(Context{
Print: func(text string) { gotProgress = append(gotProgress, text) },
})
defs2 := []ToolDef{
{
Name: "rich2",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
tc.OnProgress("hello")
return "ok", nil
},
},
}
tools2 := ExtensionToolsAsLLMTools(defs2, runner)
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
t.Errorf("expected [hello], got %v", gotProgress)
}
}
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
defs := []ToolDef{
{
Name: "both",
Execute: func(input string) (string, error) { return "simple", nil },
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
return "rich", nil
},
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "rich" {
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
}
}
func TestExtensionTool_CancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
var sawCancelled bool
defs := []ToolDef{
{
Name: "checkcancel",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
sawCancelled = tc.IsCancelled()
return "ok", nil
},
},
}
tools := ExtensionToolsAsLLMTools(defs, nil)
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
if !sawCancelled {
t.Error("expected IsCancelled=true for cancelled context")
}
}
func TestExtensionTool_ProviderOptions(t *testing.T) {
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
tools := ExtensionToolsAsLLMTools(defs, nil)
// Initially nil.
opts := tools[0].ProviderOptions()
if opts != nil {
t.Error("expected nil ProviderOptions initially")
}
// SetProviderOptions round-trips.
po := fantasy.ProviderOptions{}
tools[0].SetProviderOptions(po)
got := tools[0].ProviderOptions()
if got == nil {
t.Error("expected non-nil ProviderOptions after set")
}
}