2026-03-21 16:20:16 +02:00
|
|
|
package sidekick
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-03-23 17:10:03 +02:00
|
|
|
"errors"
|
2026-03-21 16:20:16 +02:00
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestSanitizeToolName(t *testing.T) {
|
|
|
|
|
tests := []struct{ i, e string }{
|
|
|
|
|
{"mcp/tool", "mcp_tool"},
|
|
|
|
|
{"my.tool-name", "my_tool-name"},
|
|
|
|
|
{"a space", "a_space"},
|
|
|
|
|
{"already_safe", "already_safe"},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
if a := SanitizeToolName(tt.i); a != tt.e {
|
|
|
|
|
t.Errorf("expected %s, got %s", tt.e, a)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBufferGate(t *testing.T) {
|
|
|
|
|
var tfs []string
|
|
|
|
|
threshold := 10
|
|
|
|
|
s := "small"
|
2026-03-22 12:01:29 +02:00
|
|
|
if o, _ := BufferGate(s, &tfs, threshold); o != s {
|
|
|
|
|
t.Errorf("expected small")
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
l := strings.Repeat("A", threshold+1)
|
|
|
|
|
o, err := BufferGate(l, &tfs, threshold)
|
2026-03-22 12:01:29 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("err: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(o, "file '") {
|
|
|
|
|
t.Errorf("no file pointer")
|
|
|
|
|
}
|
|
|
|
|
if len(tfs) != 1 {
|
|
|
|
|
t.Fatalf("no temp file")
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
c, _ := os.ReadFile(tfs[0])
|
2026-03-22 12:01:29 +02:00
|
|
|
if string(c) != l {
|
|
|
|
|
t.Errorf("content mismatch")
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
os.Remove(tfs[0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestJSONActionParsing(t *testing.T) {
|
|
|
|
|
a := &Sidekick{}
|
|
|
|
|
m := Message{Content: `{"action": "RESPOND", "params": {"response": "Hi"}}`}
|
|
|
|
|
act, p, ok, err := a.parseAction(m)
|
2026-03-22 12:01:29 +02:00
|
|
|
if err != nil || !ok || act != ActionTypeRespond {
|
|
|
|
|
t.Errorf("parse fail")
|
|
|
|
|
}
|
|
|
|
|
if p["response"] != "Hi" {
|
|
|
|
|
t.Errorf("param mismatch")
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDualResponseMode(t *testing.T) {
|
|
|
|
|
a := &Sidekick{}
|
|
|
|
|
m := Message{ToolCalls: []ToolCall{{Function: FunctionCall{Name: "rf", Arguments: "{}"}}}}
|
|
|
|
|
act, _, ok, _ := a.parseAction(m)
|
2026-03-22 12:01:29 +02:00
|
|
|
if !ok || act != ActionTypeToolCall {
|
|
|
|
|
t.Errorf("dual fail")
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSingleAgentLoop(t *testing.T) {
|
|
|
|
|
c := AgentConfig{ID: "a1", Role: RoleSpecialist, MaxIterations: 5}
|
|
|
|
|
a := NewSidekick(c, ModelConfig{}, nil)
|
|
|
|
|
mc := &mockLLMClient{responses: []Message{
|
|
|
|
|
{Content: `{"action": "TOOL_CALL", "params": {"tool_name": "unknown", "arguments": {}}}`},
|
|
|
|
|
{Content: `{"action": "RESPOND", "params": {"response": "Done"}}`},
|
|
|
|
|
}}
|
|
|
|
|
SetLLMClient(mc)
|
|
|
|
|
ans, _ := a.Run(context.Background(), nil, nil)
|
2026-03-22 12:01:29 +02:00
|
|
|
if ans != "Done" {
|
|
|
|
|
t.Errorf("expected Done, got %s", ans)
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDelegation(t *testing.T) {
|
|
|
|
|
sc := AgentConfig{ID: "s1", Role: RoleSpecialist, MaxIterations: 2}
|
|
|
|
|
sa := NewSidekick(sc, ModelConfig{}, nil)
|
|
|
|
|
cc := AgentConfig{ID: "c1", Role: RoleCoordinator, Subagents: []string{"s1"}, MaxIterations: 3}
|
|
|
|
|
coord := NewSidekick(cc, ModelConfig{}, nil)
|
|
|
|
|
mc := &mockLLMClient{responses: []Message{
|
|
|
|
|
{Content: `{"action": "DELEGATE", "params": {"subagent_id": "s1", "task": "do"}}`},
|
|
|
|
|
{Content: `{"action": "RESPOND", "params": {"response": "Sub done"}}`},
|
|
|
|
|
{Content: `{"action": "RESPOND", "params": {"response": "Coord done"}}`},
|
|
|
|
|
}}
|
|
|
|
|
SetLLMClient(mc)
|
|
|
|
|
p := map[string]*Sidekick{"s1": sa}
|
|
|
|
|
ans, _ := coord.Run(context.Background(), nil, p)
|
2026-03-22 12:01:29 +02:00
|
|
|
if ans != "Coord done" {
|
|
|
|
|
t.Errorf("expected Coord done, got %s", ans)
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 17:11:17 +02:00
|
|
|
func TestShellExecRegistration(t *testing.T) {
|
|
|
|
|
// Scenario 1: Disabled (default)
|
|
|
|
|
c1 := AgentConfig{ID: "a1", EnableShellExec: false}
|
|
|
|
|
a1 := NewSidekick(c1, ModelConfig{}, nil)
|
|
|
|
|
if _, ok := a1.ToolRegistry["shell_exec"]; ok {
|
|
|
|
|
t.Errorf("shell_exec should be disabled by default")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scenario 2: Enabled
|
|
|
|
|
c2 := AgentConfig{ID: "a2", EnableShellExec: true}
|
|
|
|
|
a2 := NewSidekick(c2, ModelConfig{}, nil)
|
|
|
|
|
if _, ok := a2.ToolRegistry["shell_exec"]; !ok {
|
|
|
|
|
t.Errorf("shell_exec should be enabled when EnableShellExec is true")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 16:20:16 +02:00
|
|
|
func TestTempFileCleanup(t *testing.T) {
|
|
|
|
|
threshold := 10
|
|
|
|
|
a := NewSidekick(AgentConfig{MaxIterations: 2, ToolResponseThreshold: threshold}, ModelConfig{}, nil)
|
|
|
|
|
h := strings.Repeat("B", threshold+1)
|
2026-03-22 12:01:29 +02:00
|
|
|
a.ToolRegistry["huge"] = ToolDefinition{
|
|
|
|
|
Name: "huge", Internal: true,
|
|
|
|
|
Handler: func(m map[string]interface{}) (string, error) {
|
|
|
|
|
return h, nil
|
|
|
|
|
}}
|
2026-03-21 16:20:16 +02:00
|
|
|
a.ToolMapping["huge"] = "huge"
|
|
|
|
|
mc := &mockLLMClient{responses: []Message{
|
|
|
|
|
{Content: `{"action": "TOOL_CALL", "params": {"tool_name": "huge", "arguments": {}}}`},
|
|
|
|
|
{Content: `{"action": "RESPOND", "params": {"response": "Ok"}}`},
|
|
|
|
|
}}
|
|
|
|
|
SetLLMClient(mc)
|
|
|
|
|
bfs, _ := os.ReadDir(os.TempDir())
|
|
|
|
|
a.Run(context.Background(), nil, nil)
|
|
|
|
|
afs, _ := os.ReadDir(os.TempDir())
|
|
|
|
|
bc, ac := 0, 0
|
2026-03-22 12:01:29 +02:00
|
|
|
for _, f := range bfs {
|
|
|
|
|
if strings.HasPrefix(f.Name(), "sidekick-buf-") {
|
|
|
|
|
bc++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, f := range afs {
|
|
|
|
|
if strings.HasPrefix(f.Name(), "sidekick-buf-") {
|
|
|
|
|
ac++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ac > bc {
|
|
|
|
|
t.Errorf("leak: before %d, after %d", bc, ac)
|
|
|
|
|
}
|
2026-03-21 16:20:16 +02:00
|
|
|
}
|
2026-03-23 17:10:03 +02:00
|
|
|
|
|
|
|
|
type errorLLMClient struct {
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *errorLLMClient) Call(ctx context.Context, model ModelConfig, messages []Message,
|
|
|
|
|
tools map[string]ToolDefinition, streamCallback, reasoningCallback func(string)) (Message, error) {
|
|
|
|
|
return Message{}, e.err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLLMTimeoutError(t *testing.T) {
|
|
|
|
|
SetLLMClient(&errorLLMClient{err: ErrLLMTimeout})
|
|
|
|
|
a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil)
|
|
|
|
|
_, err := a.Run(context.Background(), nil, nil)
|
|
|
|
|
if !errors.Is(err, ErrLLMTimeout) {
|
|
|
|
|
t.Errorf("expected ErrLLMTimeout, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMalformedJSONAction(t *testing.T) {
|
|
|
|
|
mc := &mockLLMClient{responses: []Message{
|
|
|
|
|
{Content: `{"action": "TOOL_CALL", "params": { "missing_brace": `},
|
|
|
|
|
}}
|
|
|
|
|
SetLLMClient(mc)
|
|
|
|
|
a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil)
|
|
|
|
|
_, err := a.Run(context.Background(), nil, nil)
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "malformed JSON action") {
|
|
|
|
|
t.Errorf("expected malformed JSON action error, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMissingActionField(t *testing.T) {
|
|
|
|
|
mc := &mockLLMClient{responses: []Message{
|
|
|
|
|
{Content: `{"params": {"foo": "bar"}}`},
|
|
|
|
|
}}
|
|
|
|
|
SetLLMClient(mc)
|
|
|
|
|
a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil)
|
|
|
|
|
_, err := a.Run(context.Background(), nil, nil)
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "missing the 'action' field") {
|
|
|
|
|
t.Errorf("expected missing action field error, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPlainTextResponse(t *testing.T) {
|
|
|
|
|
mc := &mockLLMClient{responses: []Message{
|
|
|
|
|
{Content: `This is a plain text response.`},
|
|
|
|
|
}}
|
|
|
|
|
SetLLMClient(mc)
|
|
|
|
|
a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil)
|
|
|
|
|
res, err := a.Run(context.Background(), nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Errorf("expected no error, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
if res != "This is a plain text response." {
|
|
|
|
|
t.Errorf("expected plain text response, got %s", res)
|
|
|
|
|
}
|
|
|
|
|
}
|