refactor: replace test files with upstream versions, extract fork-only tests

Replace 43 test files with their upstream/main versions to eliminate
test file merge conflicts entirely (43 files, 604 markers → 0).

Fork-only test functions are extracted to *_ext_test.go files (19 files)
which have no upstream counterpart and thus never conflict.

Source-level upstream alignment:
- config/defaults: AllowRemote defaults to true
- config/migration: model names match upstream (gpt-5.4)
- session/manager: sanitizeFilename replaces / and \
- state: log.Printf instead of log.Fatalf on mkdir failure
- wecom: verifySignature returns false on empty token (fail-closed)
- openclaw migration: preserves AllowRemote default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 03:16:04 +09:00
parent c14e3769e2
commit 1c12de7630
68 changed files with 10498 additions and 9252 deletions

View file

@ -0,0 +1,80 @@
package internal
import (
"github.com/stretchr/testify/assert"
"runtime"
"testing"
)
func TestFormatVersion_NoGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = ""
assert.Equal(t, "1.2.3", FormatVersion())
}
func TestFormatVersion_WithGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = "abc123"
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
}
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "2026-02-20T00:00:00Z"
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Equal(t, buildTime, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = ""
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Empty(t, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "x"
goVersion = ""
build, goVer := FormatBuildInfo()
assert.Equal(t, "x", build)
assert.Equal(t, runtime.Version(), goVer)
}
func TestGetVersion(t *testing.T) {
assert.Equal(t, "dev", GetVersion())
}
func TestGetConfigPath_WithEnv(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
want := "/tmp/custom/config.json"
assert.Equal(t, want, got)
}

View file

@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
assert.Equal(t, want, got) assert.Equal(t, want, got)
} }
func TestFormatVersion_NoGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = ""
assert.Equal(t, "1.2.3", FormatVersion())
}
func TestFormatVersion_WithGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = "abc123"
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
}
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "2026-02-20T00:00:00Z"
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Equal(t, buildTime, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = ""
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Empty(t, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "x"
goVersion = ""
build, goVer := FormatBuildInfo()
assert.Equal(t, "x", build)
assert.Equal(t, runtime.Version(), goVer)
}
func TestGetConfigPath_Windows(t *testing.T) { func TestGetConfigPath_Windows(t *testing.T) {
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
t.Skip("windows-specific HOME behavior varies; run on windows") t.Skip("windows-specific HOME behavior varies; run on windows")
@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want) require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
} }
func TestGetVersion(t *testing.T) {
assert.Equal(t, "dev", GetVersion())
}
func TestGetConfigPath_WithEnv(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
got := GetConfigPath()
want := "/tmp/custom/config.json"
assert.Equal(t, want, got)
}

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
) )
func TestNewPicoclawCommand(t *testing.T) { func TestNewPicoclawCommand(t *testing.T) {
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd) require.NotNil(t, cmd)
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short) assert.Equal(t, short, cmd.Short)

View file

@ -12,103 +12,70 @@ import (
) )
// setupWorkspace creates a temporary workspace with standard directories and optional files. // setupWorkspace creates a temporary workspace with standard directories and optional files.
// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir). // Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
func setupWorkspace(t *testing.T, files map[string]string) string { func setupWorkspace(t *testing.T, files map[string]string) string {
t.Helper() t.Helper()
tmpDir, err := os.MkdirTemp("", "picoclaw-test-*") tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
for name, content := range files { for name, content := range files {
dir := filepath.Dir(filepath.Join(tmpDir, name)) dir := filepath.Dir(filepath.Join(tmpDir, name))
os.MkdirAll(dir, 0o755) os.MkdirAll(dir, 0o755)
if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil { if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
return tmpDir return tmpDir
} }
// TestSingleSystemMessage verifies that BuildMessages always produces exactly one // TestSingleSystemMessage verifies that BuildMessages always produces exactly one
// system message regardless of summary/history variations. // system message regardless of summary/history variations.
// Fix: multiple system messages break Anthropic (top-level system param) and // Fix: multiple system messages break Anthropic (top-level system param) and
// Codex (only reads last system message as instructions). // Codex (only reads last system message as instructions).
func TestSingleSystemMessage(t *testing.T) { func TestSingleSystemMessage(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nTest agent.", "IDENTITY.md": "# Identity\nTest agent.",
}) })
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
tests := []struct { tests := []struct {
name string name string
history []providers.Message history []providers.Message
summary string summary string
message string message string
}{ }{
{ {
name: "no summary, no history", name: "no summary, no history",
summary: "", summary: "",
message: "hello", message: "hello",
}, },
{ {
name: "with summary", name: "with summary",
summary: "Previous conversation discussed X", summary: "Previous conversation discussed X",
message: "hello", message: "hello",
}, },
{ {
name: "with history and summary", name: "with history and summary",
history: []providers.Message{ history: []providers.Message{
{Role: "user", Content: "hi"}, {Role: "user", Content: "hi"},
{Role: "assistant", Content: "hello"}, {Role: "assistant", Content: "hello"},
}, },
summary: strings.Repeat("Long summary text. ", 50), summary: strings.Repeat("Long summary text. ", 50),
message: "new message", message: "new message",
}, },
{ {
name: "system message in history is filtered", name: "system message in history is filtered",
history: []providers.Message{ history: []providers.Message{
{Role: "system", Content: "stale system prompt from previous session"}, {Role: "system", Content: "stale system prompt from previous session"},
{Role: "user", Content: "hi"}, {Role: "user", Content: "hi"},
{Role: "assistant", Content: "hello"}, {Role: "assistant", Content: "hello"},
}, },
summary: "", summary: "",
message: "new message", message: "new message",
}, },
} }
@ -118,44 +85,35 @@ func TestSingleSystemMessage(t *testing.T) {
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1") msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
systemCount := 0 systemCount := 0
for _, m := range msgs { for _, m := range msgs {
if m.Role == "system" { if m.Role == "system" {
systemCount++ systemCount++
} }
} }
if systemCount != 1 { if systemCount != 1 {
t.Errorf("expected exactly 1 system message, got %d", systemCount) t.Errorf("expected exactly 1 system message, got %d", systemCount)
} }
if msgs[0].Role != "system" { if msgs[0].Role != "system" {
t.Errorf("first message should be system, got %s", msgs[0].Role) t.Errorf("first message should be system, got %s", msgs[0].Role)
} }
if msgs[len(msgs)-1].Role != "user" { if msgs[len(msgs)-1].Role != "user" {
t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role) t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
} }
// System message must contain identity (static) and time (dynamic) // System message must contain identity (static) and time (dynamic)
sys := msgs[0].Content sys := msgs[0].Content
if !strings.Contains(sys, "picoclaw") { if !strings.Contains(sys, "picoclaw") {
t.Error("system message missing identity") t.Error("system message missing identity")
} }
if !strings.Contains(sys, "Current Time") { if !strings.Contains(sys, "Current Time") {
t.Error("system message missing dynamic time context") t.Error("system message missing dynamic time context")
} }
// Summary handling // Summary handling
if tt.summary != "" { if tt.summary != "" {
if !strings.Contains(sys, "CONTEXT_SUMMARY:") { if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
t.Error("summary present but CONTEXT_SUMMARY prefix missing") t.Error("summary present but CONTEXT_SUMMARY prefix missing")
} }
if !strings.Contains(sys, tt.summary[:20]) { if !strings.Contains(sys, tt.summary[:20]) {
t.Error("summary content not found in system message") t.Error("summary content not found in system message")
} }
@ -169,46 +127,29 @@ func TestSingleSystemMessage(t *testing.T) {
} }
// TestMtimeAutoInvalidation verifies that the cache detects source file changes // TestMtimeAutoInvalidation verifies that the cache detects source file changes
// via mtime without requiring explicit InvalidateCache(). // via mtime without requiring explicit InvalidateCache().
// Fix: original implementation had no auto-invalidation — edits to bootstrap files, // Fix: original implementation had no auto-invalidation — edits to bootstrap files,
// memory, or skills were invisible until process restart. // memory, or skills were invisible until process restart.
func TestMtimeAutoInvalidation(t *testing.T) { func TestMtimeAutoInvalidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
file string // relative path inside workspace file string // relative path inside workspace
contentV1 string contentV1 string
contentV2 string contentV2 string
checkField string // substring to verify in rebuilt prompt checkField string // substring to verify in rebuilt prompt
}{ }{
{ {
name: "bootstrap file change", name: "bootstrap file change",
file: "IDENTITY.md", file: "IDENTITY.md",
contentV1: "# Original Identity", contentV1: "# Original Identity",
contentV2: "# Updated Identity", contentV2: "# Updated Identity",
checkField: "Updated Identity", checkField: "Updated Identity",
}, },
{ {
name: "memory file change", name: "memory file change",
file: "memory/MEMORY.md", file: "memory/MEMORY.md",
contentV1: "# Memory\nUser likes Go.", contentV1: "# Memory\nUser likes Go.",
contentV2: "# Memory\nUser likes Rust.", contentV2: "# Memory\nUser likes Rust.",
checkField: "User likes Rust", checkField: "User likes Rust",
}, },
} }
@ -216,7 +157,6 @@ func TestMtimeAutoInvalidation(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1}) tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
@ -224,39 +164,26 @@ func TestMtimeAutoInvalidation(t *testing.T) {
sp1 := cb.BuildSystemPromptWithCache() sp1 := cb.BuildSystemPromptWithCache()
// Overwrite file and set future mtime to ensure detection. // Overwrite file and set future mtime to ensure detection.
// Use 2s offset for filesystem mtime resolution safety (some FS // Use 2s offset for filesystem mtime resolution safety (some FS
// have 1s or coarser granularity, especially in CI containers). // have 1s or coarser granularity, especially in CI containers).
fullPath := filepath.Join(tmpDir, tt.file) fullPath := filepath.Join(tmpDir, tt.file)
os.WriteFile(fullPath, []byte(tt.contentV2), 0o644) os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
future := time.Now().Add(2 * time.Second) future := time.Now().Add(2 * time.Second)
os.Chtimes(fullPath, future, future) os.Chtimes(fullPath, future, future)
// Verify sourceFilesChangedLocked detects the mtime change // Verify sourceFilesChangedLocked detects the mtime change
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked() changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
if !changed { if !changed {
t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file) t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
} }
// Should auto-rebuild without explicit InvalidateCache() // Should auto-rebuild without explicit InvalidateCache()
sp2 := cb.BuildSystemPromptWithCache() sp2 := cb.BuildSystemPromptWithCache()
if sp1 == sp2 { if sp1 == sp2 {
t.Errorf("cache not rebuilt after %s change", tt.file) t.Errorf("cache not rebuilt after %s change", tt.file)
} }
if !strings.Contains(sp2, tt.checkField) { if !strings.Contains(sp2, tt.checkField) {
t.Errorf("rebuilt prompt missing expected content %q", tt.checkField) t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
} }
@ -264,34 +191,23 @@ func TestMtimeAutoInvalidation(t *testing.T) {
} }
// Skills directory mtime change // Skills directory mtime change
t.Run("skills dir change", func(t *testing.T) { t.Run("skills dir change", func(t *testing.T) {
tmpDir := setupWorkspace(t, nil) tmpDir := setupWorkspace(t, nil)
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
_ = cb.BuildSystemPromptWithCache() // populate cache _ = cb.BuildSystemPromptWithCache() // populate cache
// Touch skills directory (simulate new skill installed) // Touch skills directory (simulate new skill installed)
skillsDir := filepath.Join(tmpDir, "skills") skillsDir := filepath.Join(tmpDir, "skills")
future := time.Now().Add(2 * time.Second) future := time.Now().Add(2 * time.Second)
os.Chtimes(skillsDir, future, future) os.Chtimes(skillsDir, future, future)
// Verify sourceFilesChangedLocked detects it (cache is rebuilt) // Verify sourceFilesChangedLocked detects it (cache is rebuilt)
// We confirm by checking internal state: a second call should rebuild. // We confirm by checking internal state: a second call should rebuild.
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked() changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
if !changed { if !changed {
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change") t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
} }
@ -299,22 +215,17 @@ func TestMtimeAutoInvalidation(t *testing.T) {
} }
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild // TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
// even when source files haven't changed (useful for tests and reload commands). // even when source files haven't changed (useful for tests and reload commands).
func TestExplicitInvalidateCache(t *testing.T) { func TestExplicitInvalidateCache(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Test Identity", "IDENTITY.md": "# Test Identity",
}) })
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
sp1 := cb.BuildSystemPromptWithCache() sp1 := cb.BuildSystemPromptWithCache()
cb.InvalidateCache() cb.InvalidateCache()
sp2 := cb.BuildSystemPromptWithCache() sp2 := cb.BuildSystemPromptWithCache()
if sp1 != sp2 { if sp1 != sp2 {
@ -322,39 +233,29 @@ func TestExplicitInvalidateCache(t *testing.T) {
} }
// Verify cachedAt was reset // Verify cachedAt was reset
cb.InvalidateCache() cb.InvalidateCache()
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
if !cb.cachedAt.IsZero() { if !cb.cachedAt.IsZero() {
t.Error("cachedAt should be zero after InvalidateCache()") t.Error("cachedAt should be zero after InvalidateCache()")
} }
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
} }
// TestCacheStability verifies that the static prompt is stable across repeated calls // TestCacheStability verifies that the static prompt is stable across repeated calls
// when no files change (regression test for issue #607). // when no files change (regression test for issue #607).
func TestCacheStability(t *testing.T) { func TestCacheStability(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nContent", "IDENTITY.md": "# Identity\nContent",
"SOUL.md": "# Soul\nContent", "SOUL.md": "# Soul\nContent",
}) })
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
results := make([]string, 5) results := make([]string, 5)
for i := range results { for i := range results {
results[i] = cb.BuildSystemPromptWithCache() results[i] = cb.BuildSystemPromptWithCache()
} }
for i := 1; i < len(results); i++ { for i := 1; i < len(results); i++ {
if results[i] != results[0] { if results[i] != results[0] {
t.Errorf("cached prompt changed between call 0 and %d", i) t.Errorf("cached prompt changed between call 0 and %d", i)
@ -362,47 +263,32 @@ func TestCacheStability(t *testing.T) {
} }
// Static prompt must NOT contain per-request data // Static prompt must NOT contain per-request data
if strings.Contains(results[0], "Current Time") { if strings.Contains(results[0], "Current Time") {
t.Error("static cached prompt should not contain time (added dynamically)") t.Error("static cached prompt should not contain time (added dynamically)")
} }
} }
// TestNewFileCreationInvalidatesCache verifies that creating a source file that // TestNewFileCreationInvalidatesCache verifies that creating a source file that
// did not exist when the cache was built triggers a cache rebuild. // did not exist when the cache was built triggers a cache rebuild.
// This catches the "from nothing to something" edge case that the old // This catches the "from nothing to something" edge case that the old
// modifiedSince (return false on stat error) would miss. // modifiedSince (return false on stat error) would miss.
func TestNewFileCreationInvalidatesCache(t *testing.T) { func TestNewFileCreationInvalidatesCache(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
file string // relative path inside workspace file string // relative path inside workspace
content string content string
checkField string // substring to verify in rebuilt prompt checkField string // substring to verify in rebuilt prompt
}{ }{
{ {
name: "new bootstrap file", name: "new bootstrap file",
file: "SOUL.md", file: "SOUL.md",
content: "# Soul\nBe kind and helpful.", content: "# Soul\nBe kind and helpful.",
checkField: "Be kind and helpful", checkField: "Be kind and helpful",
}, },
{ {
name: "new memory file", name: "new memory file",
file: "memory/MEMORY.md", file: "memory/MEMORY.md",
content: "# Memory\nUser prefers dark mode.", content: "# Memory\nUser prefers dark mode.",
checkField: "User prefers dark mode", checkField: "User prefers dark mode",
}, },
} }
@ -410,41 +296,29 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Start with an empty workspace (no bootstrap/memory files) // Start with an empty workspace (no bootstrap/memory files)
tmpDir := setupWorkspace(t, nil) tmpDir := setupWorkspace(t, nil)
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
// Populate cache — file does not exist yet // Populate cache — file does not exist yet
sp1 := cb.BuildSystemPromptWithCache() sp1 := cb.BuildSystemPromptWithCache()
if strings.Contains(sp1, tt.checkField) { if strings.Contains(sp1, tt.checkField) {
t.Fatalf("prompt should not contain %q before file is created", tt.checkField) t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
} }
// Create the file after cache was built // Create the file after cache was built
fullPath := filepath.Join(tmpDir, tt.file) fullPath := filepath.Join(tmpDir, tt.file)
os.MkdirAll(filepath.Dir(fullPath), 0o755) os.MkdirAll(filepath.Dir(fullPath), 0o755)
if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil { if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Set future mtime to guarantee detection // Set future mtime to guarantee detection
future := time.Now().Add(2 * time.Second) future := time.Now().Add(2 * time.Second)
os.Chtimes(fullPath, future, future) os.Chtimes(fullPath, future, future)
// Cache should auto-invalidate because file went from absent -> present // Cache should auto-invalidate because file went from absent -> present
sp2 := cb.BuildSystemPromptWithCache() sp2 := cb.BuildSystemPromptWithCache()
if !strings.Contains(sp2, tt.checkField) { if !strings.Contains(sp2, tt.checkField) {
t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField) t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
} }
@ -453,89 +327,58 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
} }
// TestSkillFileContentChange verifies that modifying a skill file's content // TestSkillFileContentChange verifies that modifying a skill file's content
// (not just the directory structure) invalidates the cache. // (not just the directory structure) invalidates the cache.
// This is the scenario where directory mtime alone is insufficient — on most // This is the scenario where directory mtime alone is insufficient — on most
// filesystems, editing a file inside a directory does NOT update the parent // filesystems, editing a file inside a directory does NOT update the parent
// directory's mtime. // directory's mtime.
func TestSkillFileContentChange(t *testing.T) { func TestSkillFileContentChange(t *testing.T) {
skillMD := `--- skillMD := `---
name: test-skill name: test-skill
description: "A test skill" description: "A test skill"
--- ---
# Test Skill v1 # Test Skill v1
Original content.` Original content.`
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"skills/test-skill/SKILL.md": skillMD, "skills/test-skill/SKILL.md": skillMD,
}) })
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
// Populate cache // Populate cache
sp1 := cb.BuildSystemPromptWithCache() sp1 := cb.BuildSystemPromptWithCache()
_ = sp1 // cache is warm _ = sp1 // cache is warm
// Modify the skill file content (without touching the skills/ directory) // Modify the skill file content (without touching the skills/ directory)
updatedSkillMD := `--- updatedSkillMD := `---
name: test-skill name: test-skill
description: "An updated test skill" description: "An updated test skill"
--- ---
# Test Skill v2 # Test Skill v2
Updated content.` Updated content.`
skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md") skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil { if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Set future mtime on the skill file only (NOT the directory) // Set future mtime on the skill file only (NOT the directory)
future := time.Now().Add(2 * time.Second) future := time.Now().Add(2 * time.Second)
os.Chtimes(skillPath, future, future) os.Chtimes(skillPath, future, future)
// Verify that sourceFilesChangedLocked detects the content change // Verify that sourceFilesChangedLocked detects the content change
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked() changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
if !changed { if !changed {
t.Error("sourceFilesChangedLocked() should detect skill file content change") t.Error("sourceFilesChangedLocked() should detect skill file content change")
} }
// Verify cache is actually rebuilt with new content // Verify cache is actually rebuilt with new content
sp2 := cb.BuildSystemPromptWithCache() sp2 := cb.BuildSystemPromptWithCache()
if sp1 == sp2 && strings.Contains(sp1, "test-skill") { if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
// If the skill appeared in the prompt and the prompt didn't change, // If the skill appeared in the prompt and the prompt didn't change,
// the cache was not invalidated. // the cache was not invalidated.
t.Error("cache should be invalidated when skill file content changes") t.Error("cache should be invalidated when skill file content changes")
} }
} }
@ -697,75 +540,53 @@ description: delete-me-v1
} }
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines // TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
// can safely call BuildSystemPromptWithCache concurrently without producing // can safely call BuildSystemPromptWithCache concurrently without producing
// empty results, panics, or data races. // empty results, panics, or data races.
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache // Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nConcurrency test agent.", "IDENTITY.md": "# Identity\nConcurrency test agent.",
"SOUL.md": "# Soul\nBe helpful.", "SOUL.md": "# Soul\nBe helpful.",
"memory/MEMORY.md": "# Memory\nUser prefers Go.", "memory/MEMORY.md": "# Memory\nUser prefers Go.",
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
}) })
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
const goroutines = 20 const goroutines = 20
const iterations = 50 const iterations = 50
var wg sync.WaitGroup var wg sync.WaitGroup
errs := make(chan string, goroutines*iterations) errs := make(chan string, goroutines*iterations)
for g := range goroutines { for g := range goroutines {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
for i := range iterations { for i := range iterations {
result := cb.BuildSystemPromptWithCache() result := cb.BuildSystemPromptWithCache()
if result == "" { if result == "" {
errs <- "empty prompt returned" errs <- "empty prompt returned"
return return
} }
if !strings.Contains(result, "picoclaw") { if !strings.Contains(result, "picoclaw") {
errs <- "prompt missing identity" errs <- "prompt missing identity"
return return
} }
// Also exercise BuildMessages concurrently // Also exercise BuildMessages concurrently
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat") msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
if len(msgs) < 2 { if len(msgs) < 2 {
errs <- "BuildMessages returned fewer than 2 messages" errs <- "BuildMessages returned fewer than 2 messages"
return return
} }
if msgs[0].Role != "system" { if msgs[0].Role != "system" {
errs <- "first message not system" errs <- "first message not system"
return return
} }
// Occasionally invalidate to exercise the write path // Occasionally invalidate to exercise the write path
if i%10 == 0 { if i%10 == 0 {
cb.InvalidateCache() cb.InvalidateCache()
} }
@ -774,7 +595,6 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
} }
wg.Wait() wg.Wait()
close(errs) close(errs)
for errMsg := range errs { for errMsg := range errs {
@ -785,90 +605,64 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
// BenchmarkBuildMessagesWithCache measures caching performance. // BenchmarkBuildMessagesWithCache measures caching performance.
// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is // TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
// built on an empty workspace (no tracked files exist), creating a file // built on an empty workspace (no tracked files exist), creating a file
// afterwards still triggers cache invalidation. This validates the // afterwards still triggers cache invalidation. This validates the
// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch, // time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
// so fileChangedSince correctly detects the absent -> present transition AND // so fileChangedSince correctly detects the absent -> present transition AND
// the mtime comparison succeeds even without artificially inflated Chtimes. // the mtime comparison succeeds even without artificially inflated Chtimes.
func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) { func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
// Empty workspace: no bootstrap files, no memory, no skills content. // Empty workspace: no bootstrap files, no memory, no skills content.
tmpDir := setupWorkspace(t, nil) tmpDir := setupWorkspace(t, nil)
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
// Build cache — all tracked files are absent, maxMtime falls back to epoch. // Build cache — all tracked files are absent, maxMtime falls back to epoch.
sp1 := cb.BuildSystemPromptWithCache() sp1 := cb.BuildSystemPromptWithCache()
// Create a bootstrap file with natural mtime (no Chtimes manipulation). // Create a bootstrap file with natural mtime (no Chtimes manipulation).
// The file's mtime should be the current wall-clock time, which is // The file's mtime should be the current wall-clock time, which is
// strictly after time.Unix(1, 0). // strictly after time.Unix(1, 0).
soulPath := filepath.Join(tmpDir, "SOUL.md") soulPath := filepath.Join(tmpDir, "SOUL.md")
if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil { if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Cache should detect the new file via existedAtCache (absent -> present). // Cache should detect the new file via existedAtCache (absent -> present).
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked() changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
if !changed { if !changed {
t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace") t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
} }
sp2 := cb.BuildSystemPromptWithCache() sp2 := cb.BuildSystemPromptWithCache()
if !strings.Contains(sp2, "Newly created") { if !strings.Contains(sp2, "Newly created") {
t.Error("rebuilt prompt should contain new file content") t.Error("rebuilt prompt should contain new file content")
} }
if sp1 == sp2 { if sp1 == sp2 {
t.Error("cache should have been invalidated after file creation") t.Error("cache should have been invalidated after file creation")
} }
} }
// BenchmarkBuildMessagesWithCache measures caching performance. // BenchmarkBuildMessagesWithCache measures caching performance.
func BenchmarkBuildMessagesWithCache(b *testing.B) { func BenchmarkBuildMessagesWithCache(b *testing.B) {
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*") tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} { for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644) os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
} }
cb := NewContextBuilder(tmpDir) cb := NewContextBuilder(tmpDir)
history := []providers.Message{ history := []providers.Message{
{Role: "user", Content: "previous message"}, {Role: "user", Content: "previous message"},
{Role: "assistant", Content: "previous response"}, {Role: "assistant", Content: "previous response"},
} }
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test") _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
} }

View file

@ -12,11 +12,9 @@ func msg(role, content string) providers.Message {
func assistantWithTools(toolIDs ...string) providers.Message { func assistantWithTools(toolIDs ...string) providers.Message {
calls := make([]providers.ToolCall, len(toolIDs)) calls := make([]providers.ToolCall, len(toolIDs))
for i, id := range toolIDs { for i, id := range toolIDs {
calls[i] = providers.ToolCall{ID: id, Type: "function"} calls[i] = providers.ToolCall{ID: id, Type: "function"}
} }
return providers.Message{Role: "assistant", ToolCalls: calls} return providers.Message{Role: "assistant", ToolCalls: calls}
} }
@ -26,13 +24,11 @@ func toolResult(id string) providers.Message {
func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) { func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
result := sanitizeHistoryForProvider(nil) result := sanitizeHistoryForProvider(nil)
if len(result) != 0 { if len(result) != 0 {
t.Fatalf("expected empty, got %d messages", len(result)) t.Fatalf("expected empty, got %d messages", len(result))
} }
result = sanitizeHistoryForProvider([]providers.Message{}) result = sanitizeHistoryForProvider([]providers.Message{})
if len(result) != 0 { if len(result) != 0 {
t.Fatalf("expected empty, got %d messages", len(result)) t.Fatalf("expected empty, got %d messages", len(result))
} }
@ -41,228 +37,170 @@ func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) { func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "hello"), msg("user", "hello"),
assistantWithTools("A"), assistantWithTools("A"),
toolResult("A"), toolResult("A"),
msg("assistant", "done"), msg("assistant", "done"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 4 { if len(result) != 4 {
t.Fatalf("expected 4 messages, got %d", len(result)) t.Fatalf("expected 4 messages, got %d", len(result))
} }
assertRoles(t, result, "user", "assistant", "tool", "assistant") assertRoles(t, result, "user", "assistant", "tool", "assistant")
} }
func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) { func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "do two things"), msg("user", "do two things"),
assistantWithTools("A", "B"), assistantWithTools("A", "B"),
toolResult("A"), toolResult("A"),
toolResult("B"), toolResult("B"),
msg("assistant", "both done"), msg("assistant", "both done"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 5 { if len(result) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant") assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
} }
func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) { func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "hi"), msg("user", "hi"),
msg("assistant", "thinking"), msg("assistant", "thinking"),
assistantWithTools("A"), assistantWithTools("A"),
toolResult("A"), toolResult("A"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 2 { if len(result) != 2 {
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user", "assistant") assertRoles(t, result, "user", "assistant")
} }
func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) { func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
toolResult("A"), toolResult("A"),
msg("user", "hello"), msg("user", "hello"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user") assertRoles(t, result, "user")
} }
func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) { func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "hello"), msg("user", "hello"),
toolResult("A"), toolResult("A"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user") assertRoles(t, result, "user")
} }
func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) { func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "hello"), msg("user", "hello"),
msg("assistant", "hi"), msg("assistant", "hi"),
toolResult("A"), toolResult("A"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 2 { if len(result) != 2 {
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user", "assistant") assertRoles(t, result, "user", "assistant")
} }
func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) { func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
assistantWithTools("A"), assistantWithTools("A"),
toolResult("A"), toolResult("A"),
msg("user", "hello"), msg("user", "hello"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user") assertRoles(t, result, "user")
} }
func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) { func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "do two things"), msg("user", "do two things"),
assistantWithTools("A", "B"), assistantWithTools("A", "B"),
toolResult("A"), toolResult("A"),
toolResult("B"), toolResult("B"),
msg("assistant", "done"), msg("assistant", "done"),
msg("user", "hi"), msg("user", "hi"),
assistantWithTools("C"), assistantWithTools("C"),
toolResult("C"), toolResult("C"),
msg("assistant", "done again"), msg("assistant", "done again"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 9 { if len(result) != 9 {
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant") assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
} }
func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "start"), msg("user", "start"),
assistantWithTools("A", "B"), assistantWithTools("A", "B"),
toolResult("A"), toolResult("A"),
toolResult("B"), toolResult("B"),
assistantWithTools("C", "D"), assistantWithTools("C", "D"),
toolResult("C"), toolResult("C"),
toolResult("D"), toolResult("D"),
msg("assistant", "all done"), msg("assistant", "all done"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 8 { if len(result) != 8 {
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
} }
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant") assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
} }
func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
history := []providers.Message{ history := []providers.Message{
msg("user", "hello"), msg("user", "hello"),
msg("assistant", "hi"), msg("assistant", "hi"),
msg("user", "how are you"), msg("user", "how are you"),
msg("assistant", "fine"), msg("assistant", "fine"),
} }
result := sanitizeHistoryForProvider(history) result := sanitizeHistoryForProvider(history)
if len(result) != 4 { if len(result) != 4 {
t.Fatalf("expected 4 messages, got %d", len(result)) t.Fatalf("expected 4 messages, got %d", len(result))
} }
assertRoles(t, result, "user", "assistant", "user", "assistant") assertRoles(t, result, "user", "assistant", "user", "assistant")
} }
func roles(msgs []providers.Message) []string { func roles(msgs []providers.Message) []string {
r := make([]string, len(msgs)) r := make([]string, len(msgs))
for i, m := range msgs { for i, m := range msgs {
r[i] = m.Role r[i] = m.Role
} }
return r return r
} }
func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) { func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
t.Helper() t.Helper()
if len(msgs) != len(expected) { if len(msgs) != len(expected) {
t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected) t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
} }
for i, exp := range expected { for i, exp := range expected {
if msgs[i].Role != exp { if msgs[i].Role != exp {
t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp) t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)

View file

@ -0,0 +1,52 @@
package agent
import (
"github.com/sipeed/picoclaw/pkg/config"
"os"
"testing"
)
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openai" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
}
}

View file

@ -12,35 +12,28 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cfg := &config.Config{ cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", Model: "test-model",
MaxTokens: 1234, MaxTokens: 1234,
MaxToolIterations: 5, MaxToolIterations: 5,
}, },
}, },
} }
configuredTemp := 1.0 configuredTemp := 1.0
cfg.Agents.Defaults.Temperature = &configuredTemp cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.MaxTokens != 1234 { if agent.MaxTokens != 1234 {
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234) t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
} }
if agent.Temperature != 1.0 { if agent.Temperature != 1.0 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0) t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
} }
@ -51,29 +44,23 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cfg := &config.Config{ cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", Model: "test-model",
MaxTokens: 1234, MaxTokens: 1234,
MaxToolIterations: 5, MaxToolIterations: 5,
}, },
}, },
} }
configuredTemp := 0.0 configuredTemp := 0.0
cfg.Agents.Defaults.Temperature = &configuredTemp cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Temperature != 0.0 { if agent.Temperature != 0.0 {
@ -86,25 +73,20 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cfg := &config.Config{ cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: "test-model", Model: "test-model",
MaxTokens: 1234, MaxTokens: 1234,
MaxToolIterations: 5, MaxToolIterations: 5,
}, },
}, },
} }
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Temperature != 0.7 { if agent.Temperature != 0.7 {
@ -113,91 +95,68 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
} }
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
tests := []struct {
name string
aliasName string
modelName string
apiBase string
wantProvider string
wantModel string
}{
{
name: "alias with provider prefix",
aliasName: "step-3.5-flash",
modelName: "openrouter/stepfun/step-3.5-flash:free",
apiBase: "https://openrouter.ai/api/v1",
wantProvider: "openrouter",
wantModel: "stepfun/step-3.5-flash:free",
},
{
name: "alias without provider prefix",
aliasName: "glm-5",
modelName: "glm-5",
apiBase: "https://api.z.ai/api/coding/paas/v4",
wantProvider: "openai",
wantModel: "glm-5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
cfg := &config.Config{ cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: tt.aliasName,
Model: "step-3.5-flash",
}, },
}, },
ModelList: []config.ModelConfig{ ModelList: []config.ModelConfig{
{ {
ModelName: "step-3.5-flash", ModelName: tt.aliasName,
Model: tt.modelName,
Model: "openrouter/stepfun/step-3.5-flash:free", APIBase: tt.apiBase,
APIBase: "https://openrouter.ai/api/v1",
}, },
}, },
} }
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 { if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
} }
if agent.Candidates[0].Provider != tt.wantProvider {
if agent.Candidates[0].Provider != "openrouter" { t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider)
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
} }
if agent.Candidates[0].Model != tt.wantModel {
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" { t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free") }
} })
}
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openai" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
} }
} }

2918
pkg/agent/loop_ext_test.go Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,18 +10,13 @@ type mockProvider struct{}
func (m *mockProvider) Chat( func (m *mockProvider) Chat(
ctx context.Context, ctx context.Context,
messages []providers.Message, messages []providers.Message,
tools []providers.ToolDefinition, tools []providers.ToolDefinition,
model string, model string,
opts map[string]any, opts map[string]any,
) (*providers.LLMResponse, error) { ) (*providers.LLMResponse, error) {
return &providers.LLMResponse{ return &providers.LLMResponse{
Content: "Mock response", Content: "Mock response",
ToolCalls: []providers.ToolCall{}, ToolCalls: []providers.ToolCall{},
}, nil }, nil
} }

View file

@ -12,13 +12,9 @@ type mockRegistryProvider struct{}
func (m *mockRegistryProvider) Chat( func (m *mockRegistryProvider) Chat(
ctx context.Context, ctx context.Context,
messages []providers.Message, messages []providers.Message,
tools []providers.ToolDefinition, tools []providers.ToolDefinition,
model string, model string,
options map[string]any, options map[string]any,
) (*providers.LLMResponse, error) { ) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
@ -33,14 +29,10 @@ func testCfg(agents []config.AgentConfig) *config.Config {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test-registry", Workspace: "/tmp/picoclaw-test-registry",
Model: "gpt-4", Model: "gpt-4",
MaxTokens: 8192, MaxTokens: 8192,
MaxToolIterations: 10, MaxToolIterations: 10,
}, },
List: agents, List: agents,
}, },
} }
@ -48,21 +40,17 @@ func testCfg(agents []config.AgentConfig) *config.Config {
func TestNewAgentRegistry_ImplicitMain(t *testing.T) { func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
cfg := testCfg(nil) cfg := testCfg(nil)
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
ids := registry.ListAgentIDs() ids := registry.ListAgentIDs()
if len(ids) != 1 || ids[0] != "main" { if len(ids) != 1 || ids[0] != "main" {
t.Errorf("expected implicit main agent, got %v", ids) t.Errorf("expected implicit main agent, got %v", ids)
} }
agent, ok := registry.GetAgent("main") agent, ok := registry.GetAgent("main")
if !ok || agent == nil { if !ok || agent == nil {
t.Fatal("expected to find 'main' agent") t.Fatal("expected to find 'main' agent")
} }
if agent.ID != "main" { if agent.ID != "main" {
t.Errorf("agent.ID = %q, want 'main'", agent.ID) t.Errorf("agent.ID = %q, want 'main'", agent.ID)
} }
@ -71,30 +59,24 @@ func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) { func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "sales", Default: true, Name: "Sales Bot"}, {ID: "sales", Default: true, Name: "Sales Bot"},
{ID: "support", Name: "Support Bot"}, {ID: "support", Name: "Support Bot"},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
ids := registry.ListAgentIDs() ids := registry.ListAgentIDs()
if len(ids) != 2 { if len(ids) != 2 {
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids) t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
} }
sales, ok := registry.GetAgent("sales") sales, ok := registry.GetAgent("sales")
if !ok || sales == nil { if !ok || sales == nil {
t.Fatal("expected to find 'sales' agent") t.Fatal("expected to find 'sales' agent")
} }
if sales.Name != "Sales Bot" { if sales.Name != "Sales Bot" {
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name) t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
} }
support, ok := registry.GetAgent("support") support, ok := registry.GetAgent("support")
if !ok || support == nil { if !ok || support == nil {
t.Fatal("expected to find 'support' agent") t.Fatal("expected to find 'support' agent")
} }
@ -104,15 +86,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "my-agent", Default: true}, {ID: "my-agent", Default: true},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, ok := registry.GetAgent("My-Agent") agent, ok := registry.GetAgent("My-Agent")
if !ok || agent == nil { if !ok || agent == nil {
t.Fatal("expected to find agent with normalized ID") t.Fatal("expected to find agent with normalized ID")
} }
if agent.ID != "my-agent" { if agent.ID != "my-agent" {
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID) t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
} }
@ -121,16 +100,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
func TestAgentRegistry_GetDefaultAgent(t *testing.T) { func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "alpha"}, {ID: "alpha"},
{ID: "beta", Default: true}, {ID: "beta", Default: true},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
// GetDefaultAgent first checks for "main", then returns any // GetDefaultAgent first checks for "main", then returns any
agent := registry.GetDefaultAgent() agent := registry.GetDefaultAgent()
if agent == nil { if agent == nil {
t.Fatal("expected a default agent") t.Fatal("expected a default agent")
} }
@ -140,35 +115,26 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ {
ID: "parent", ID: "parent",
Default: true, Default: true,
Subagents: &config.SubagentsConfig{ Subagents: &config.SubagentsConfig{
AllowAgents: []string{"child1", "child2"}, AllowAgents: []string{"child1", "child2"},
}, },
}, },
{ID: "child1"}, {ID: "child1"},
{ID: "child2"}, {ID: "child2"},
{ID: "restricted"}, {ID: "restricted"},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
if !registry.CanSpawnSubagent("parent", "child1") { if !registry.CanSpawnSubagent("parent", "child1") {
t.Error("expected parent to be allowed to spawn child1") t.Error("expected parent to be allowed to spawn child1")
} }
if !registry.CanSpawnSubagent("parent", "child2") { if !registry.CanSpawnSubagent("parent", "child2") {
t.Error("expected parent to be allowed to spawn child2") t.Error("expected parent to be allowed to spawn child2")
} }
if registry.CanSpawnSubagent("parent", "restricted") { if registry.CanSpawnSubagent("parent", "restricted") {
t.Error("expected parent to NOT be allowed to spawn restricted") t.Error("expected parent to NOT be allowed to spawn restricted")
} }
if registry.CanSpawnSubagent("child1", "child2") { if registry.CanSpawnSubagent("child1", "child2") {
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)") t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
} }
@ -178,23 +144,18 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ {
ID: "admin", ID: "admin",
Default: true, Default: true,
Subagents: &config.SubagentsConfig{ Subagents: &config.SubagentsConfig{
AllowAgents: []string{"*"}, AllowAgents: []string{"*"},
}, },
}, },
{ID: "any-agent"}, {ID: "any-agent"},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
if !registry.CanSpawnSubagent("admin", "any-agent") { if !registry.CanSpawnSubagent("admin", "any-agent") {
t.Error("expected wildcard to allow spawning any agent") t.Error("expected wildcard to allow spawning any agent")
} }
if !registry.CanSpawnSubagent("admin", "nonexistent") { if !registry.CanSpawnSubagent("admin", "nonexistent") {
t.Error("expected wildcard to allow spawning even nonexistent agents") t.Error("expected wildcard to allow spawning even nonexistent agents")
} }
@ -202,15 +163,12 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
func TestAgentInstance_Model(t *testing.T) { func TestAgentInstance_Model(t *testing.T) {
model := &config.AgentModelConfig{Primary: "claude-opus"} model := &config.AgentModelConfig{Primary: "claude-opus"}
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "custom", Default: true, Model: model}, {ID: "custom", Default: true, Model: model},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("custom") agent, _ := registry.GetAgent("custom")
if agent.Model != "claude-opus" { if agent.Model != "claude-opus" {
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model) t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
} }
@ -220,13 +178,10 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "inherit", Default: true}, {ID: "inherit", Default: true},
}) })
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"} cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("inherit") agent, _ := registry.GetAgent("inherit")
if len(agent.Fallbacks) != 2 { if len(agent.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks)) t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
} }
@ -235,21 +190,15 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
model := &config.AgentModelConfig{ model := &config.AgentModelConfig{
Primary: "gpt-4", Primary: "gpt-4",
Fallbacks: []string{}, // explicitly empty = disable Fallbacks: []string{}, // explicitly empty = disable
} }
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "no-fallback", Default: true, Model: model}, {ID: "no-fallback", Default: true, Model: model},
}) })
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"} cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("no-fallback") agent, _ := registry.GetAgent("no-fallback")
if len(agent.Fallbacks) != 0 { if len(agent.Fallbacks) != 0 {
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks) t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
} }

View file

@ -0,0 +1,881 @@
package channels
import (
"context"
"fmt"
"github.com/sipeed/picoclaw/pkg/bus"
"golang.org/x/time/rate"
"sync/atomic"
"testing"
"time"
)
// mockEditorWithSendID implements MessageEditor and MessageSenderWithID.
type mockEditorWithSendID struct {
mockChannel
editFn func(ctx context.Context, chatID, messageID, content string) error
sendWithID func(ctx context.Context, chatID, content string) (string, error)
}
func (m *mockEditorWithSendID) SendWithID(ctx context.Context, chatID, content string) (string, error) {
return m.sendWithID(ctx, chatID, content)
}
func TestHandleStatusSend_EditsPlaceholder(t *testing.T) {
m := newTestManager()
var editCalled bool
var editedContent string
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, content string) error {
editCalled = true
editedContent = content
if messageID != "ph-42" {
t.Fatalf("expected messageID ph-42, got %s", messageID)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when placeholder exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.RecordPlaceholder("test", "123", "ph-42")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "status update 1", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called on placeholder")
}
if editedContent != "status update 1" {
t.Fatalf("expected content 'status update 1', got %s", editedContent)
}
}
func TestHandleStatusSend_EditsTrackedStatus(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCalled = true
if messageID != "status-99" {
t.Fatalf("expected messageID status-99, got %s", messageID)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when statusMsgID exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-99", createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "update 2", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called on tracked status message")
}
}
func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) {
m := newTestManager()
var sendWithIDCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil
},
sendWithID: func(_ context.Context, chatID, content string) (string, error) {
sendWithIDCalled = true
if chatID != "123" {
t.Fatalf("expected chatID 123, got %s", chatID)
}
return "new-msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !sendWithIDCalled {
t.Fatal("expected SendWithID to be called")
}
v, ok := m.statusMsgIDs.Load("test:123")
if !ok {
t.Fatal("expected statusMsgIDs to contain tracked entry")
}
entry := v.(statusMsgEntry)
if entry.messageID != "new-msg-1" {
t.Fatalf("expected messageID new-msg-1, got %s", entry.messageID)
}
}
func TestHandleTaskStatusSend_EditsExisting(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, content string) error {
editCalled = true
if messageID != "task-msg-1" {
t.Fatalf("expected messageID task-msg-1, got %s", messageID)
}
if content != "task progress 50%" {
t.Fatalf("expected content 'task progress 50%%', got %s", content)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when task message exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.taskMsgIDs.Store(
taskStatusKey("test", "123", "task-abc"),
statusMsgEntry{messageID: "task-msg-1", createdAt: time.Now()},
)
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task progress 50%",
IsTaskStatus: true,
TaskID: "task-abc",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
}
func TestHandleTaskStatusSend_SendsNewAndTracks(t *testing.T) {
m := newTestManager()
var sendWithIDCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCalled = true
return "new-task-msg", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task started",
IsTaskStatus: true,
TaskID: "task-xyz",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !sendWithIDCalled {
t.Fatal("expected SendWithID to be called")
}
v, ok := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-xyz"))
if !ok {
t.Fatal("expected taskMsgIDs to contain tracked entry")
}
entry := v.(statusMsgEntry)
if entry.messageID != "new-task-msg" {
t.Fatalf("expected messageID new-task-msg, got %s", entry.messageID)
}
}
func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) {
m := newTestManager()
var sendCalled bool
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
sendCalled = true
if msg.Content != "task status" {
t.Fatalf("expected content 'task status', got %s", msg.Content)
}
return nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task status",
IsTaskStatus: true,
TaskID: "task-fallback",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !sendCalled {
t.Fatal("expected fallback Send to be called")
}
}
func TestPreSend_EditsStatusMessage(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCalled = true
if messageID != "status-msg-77" {
t.Fatalf("expected messageID status-msg-77, got %s", messageID)
}
return nil
},
}
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-msg-77", createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if !edited {
t.Fatal("expected preSend to return true (status message edited)")
}
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
t.Fatal("expected statusMsgIDs entry to be deleted after preSend")
}
}
func TestRunWorker_RoutesStatusMessages(t *testing.T) {
m := newTestManager()
var regularSendCount atomic.Int32
var sendWithIDCount atomic.Int32
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
regularSendCount.Add(1)
return nil
},
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount.Add(1)
return "tracked-1", nil
},
}
w := &channelWorker{
ch: ch,
queue: make(chan bus.OutboundMessage, 10),
done: make(chan struct{}),
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.runWorker(ctx, "test", w)
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true}
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"}
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"}
time.Sleep(200 * time.Millisecond)
if regularSendCount.Load() != 1 {
t.Fatalf("expected 1 regular Send call, got %d", regularSendCount.Load())
}
if sendWithIDCount.Load() != 2 {
t.Fatalf("expected 2 SendWithID calls (status + task), got %d", sendWithIDCount.Load())
}
}
func TestStatusMsgTTLJanitor(t *testing.T) {
m := newTestManager()
m.statusMsgIDs.Store("test:old", statusMsgEntry{
messageID: "old-status",
createdAt: time.Now().Add(-10 * time.Minute),
})
m.taskMsgIDs.Store("task-old", statusMsgEntry{
messageID: "old-task",
createdAt: time.Now().Add(-60 * time.Minute),
})
m.statusMsgIDs.Store("test:fresh", statusMsgEntry{
messageID: "fresh-status",
createdAt: time.Now(),
})
now := time.Now()
m.statusMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > statusMsgTTL {
m.statusMsgIDs.Delete(key)
}
}
return true
})
m.taskMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > taskMsgTTL {
m.taskMsgIDs.Delete(key)
}
}
return true
})
if _, loaded := m.statusMsgIDs.Load("test:old"); loaded {
t.Fatal("expected old status entry to be evicted")
}
if _, loaded := m.taskMsgIDs.Load("task-old"); loaded {
t.Fatal("expected old task entry to be evicted")
}
if _, loaded := m.statusMsgIDs.Load("test:fresh"); !loaded {
t.Fatal("expected fresh status entry to survive")
}
}
// mockDraftSender implements DraftSender + MessageSenderWithID + MessageEditor.
type mockDraftSender struct {
mockChannel
draftFn func(ctx context.Context, chatID string, draftID int, content string) error
editFn func(ctx context.Context, chatID, messageID, content string) error
sendWithID func(ctx context.Context, chatID, content string) (string, error)
}
func (m *mockDraftSender) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
return m.draftFn(ctx, chatID, draftID, content)
}
func (m *mockDraftSender) SendWithID(ctx context.Context, chatID, content string) (string, error) {
return m.sendWithID(ctx, chatID, content)
}
func TestHandleStatusSend_UsesDraftSender(t *testing.T) {
m := newTestManager()
var draftCalled bool
var draftContent string
var draftDID int
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
draftCalled = true
draftContent = content
draftDID = draftID
return nil
},
editFn: func(_ context.Context, _, _, _ string) error {
t.Fatal("EditMessage should not be called when draft succeeds")
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when draft succeeds")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "streaming preview", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !draftCalled {
t.Fatal("expected SendDraft to be called")
}
if draftContent != "streaming preview" {
t.Fatalf("expected draft content 'streaming preview', got %s", draftContent)
}
if draftDID == 0 {
t.Fatal("expected non-zero draftID")
}
draftCalled = false
var secondDID int
ch.draftFn = func(_ context.Context, _ string, draftID int, _ string) error {
draftCalled = true
secondDID = draftID
return nil
}
msg.Content = "streaming preview updated"
m.handleStatusSend(context.Background(), "test", w, msg)
if !draftCalled {
t.Fatal("expected SendDraft to be called again")
}
if secondDID != draftDID {
t.Fatalf("expected same draftID %d, got %d", draftDID, secondDID)
}
}
func TestHandleStatusSend_DraftFails_FallsToEdit(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
return fmt.Errorf("draft not supported in group")
},
editFn: func(_ context.Context, _, _, _ string) error {
editCalled = true
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
return "msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "preview", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if editCalled {
t.Fatal("expected EditMessage NOT to be called (no placeholder)")
}
}
func TestHandleStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) {
m := newTestManager()
var sendWithIDCount int
var editCount int
var editedMessageID string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
return fmt.Errorf("draft unsupported")
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCount++
editedMessageID = messageID
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount++
return "msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "group-main", Content: "preview-1", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
msg.Content = "preview-2"
m.handleStatusSend(context.Background(), "test", w, msg)
if sendWithIDCount != 1 {
t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount)
}
if editCount != 1 {
t.Fatalf("expected EditMessage to be called once, got %d", editCount)
}
if editedMessageID != "msg-1" {
t.Fatalf("expected EditMessage target msg-1, got %s", editedMessageID)
}
}
func TestHandleTaskStatusSend_UsesDraftSender(t *testing.T) {
m := newTestManager()
var draftCalled bool
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
draftCalled = true
return nil
},
editFn: func(_ context.Context, _, _, _ string) error {
t.Fatal("EditMessage should not be called when draft succeeds")
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when draft succeeds")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task progress 50%",
IsTaskStatus: true,
TaskID: "task-draft",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !draftCalled {
t.Fatal("expected SendDraft to be called for task status")
}
}
func TestHandleTaskStatusSend_Final_UpdatesDraftInPlace(t *testing.T) {
m := newTestManager()
var draftUpdateCalled bool
var draftUpdateDraftID int
var draftUpdateContent string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
t.Fatal("Send should not be called when draft update succeeds")
return nil
},
},
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
draftUpdateCalled = true
draftUpdateDraftID = draftID
draftUpdateContent = content
if chatID != "123" {
t.Fatalf("expected chatID 123, got %s", chatID)
}
return nil
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when draft update succeeds")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.taskMsgIDs.Store(taskStatusKey("test", "123", "task-final"), statusMsgEntry{draftID: 42, createdAt: time.Now()})
m.statusEditTimes.Store(taskStatusKey("test", "123", "task-final"), time.Now())
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task completed",
IsTaskStatus: true,
TaskID: "task-final",
Final: true,
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !draftUpdateCalled {
t.Fatal("expected SendDraft to update draft with final content")
}
if draftUpdateDraftID != 42 {
t.Fatalf("expected draftID 42, got %d", draftUpdateDraftID)
}
if draftUpdateContent != "task completed" {
t.Fatalf("expected draft content 'task completed', got %q", draftUpdateContent)
}
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-final")); loaded {
t.Fatal("expected taskMsgIDs entry to be deleted for final task status")
}
if _, loaded := m.statusEditTimes.Load(taskStatusKey("test", "123", "task-final")); loaded {
t.Fatal("expected statusEditTimes entry to be deleted for final task status")
}
}
func TestHandleTaskStatusSend_DraftStreaming_IsolatedByChatThread(t *testing.T) {
m := newTestManager()
type draftCall struct {
chatID string
draftID int
content string
}
calls := make([]draftCall, 0, 2)
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
calls = append(calls, draftCall{chatID: chatID, draftID: draftID, content: content})
return nil
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msgA := bus.OutboundMessage{
Channel: "test",
ChatID: "-100/10",
Content: "A:10%",
IsTaskStatus: true,
TaskID: "shared-task",
}
msgB := bus.OutboundMessage{
Channel: "test",
ChatID: "-100/20",
Content: "B:10%",
IsTaskStatus: true,
TaskID: "shared-task",
}
m.handleTaskStatusSend(context.Background(), "test", w, msgA)
m.handleTaskStatusSend(context.Background(), "test", w, msgB)
if len(calls) != 2 {
t.Fatalf("expected 2 SendDraft calls, got %d", len(calls))
}
if calls[0].chatID == calls[1].chatID {
t.Fatalf("expected different chat threads, got %q and %q", calls[0].chatID, calls[1].chatID)
}
if calls[0].draftID == calls[1].draftID {
t.Fatalf("expected distinct draft IDs per thread key, both got %d", calls[0].draftID)
}
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/10", "shared-task")); !loaded {
t.Fatal("expected taskMsgIDs entry for thread A")
}
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/20", "shared-task")); !loaded {
t.Fatal("expected taskMsgIDs entry for thread B")
}
}
func TestHandleTaskStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) {
m := newTestManager()
var sendWithIDCount int
var editCount int
var editedMessageID string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
return fmt.Errorf("draft unsupported")
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCount++
editedMessageID = messageID
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount++
return "task-msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "group-main",
Content: "task-10%",
IsTaskStatus: true,
TaskID: "task-1",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
msg.Content = "task-20%"
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if sendWithIDCount != 1 {
t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount)
}
if editCount != 1 {
t.Fatalf("expected EditMessage to be called once, got %d", editCount)
}
if editedMessageID != "task-msg-1" {
t.Fatalf("expected EditMessage target task-msg-1, got %s", editedMessageID)
}
}
func TestPreSend_ClearsDraftState(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
}
m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if edited {
t.Fatal("expected preSend to return false for draft-based status (sendMessage replaces draft)")
}
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
t.Fatal("expected draft status entry to be deleted after preSend")
}
}
func TestGenerateDraftID_Stable(t *testing.T) {
id1 := generateDraftID("telegram:123")
id2 := generateDraftID("telegram:123")
if id1 != id2 {
t.Fatalf("expected stable draft ID, got %d vs %d", id1, id2)
}
if id1 == 0 {
t.Fatal("expected non-zero draft ID")
}
id3 := generateDraftID("telegram:456")
if id1 == id3 {
t.Fatalf("expected different draft IDs for different keys, both got %d", id1)
}
}
// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly
// dismisses a draft-based status bubble (via SendDraft with empty text)
// before proceeding to send the permanent message. This prevents ghost
// draft bubbles when a user message arrives between the last draft update
// and the final sendMessage.
// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly
// dismisses a draft-based status bubble (via SendDraft with empty text)
// before proceeding to send the permanent message. This prevents ghost
// draft bubbles when a user message arrives between the last draft update
// and the final sendMessage.
func TestPreSend_DismissesDraftBeforeSend(t *testing.T) {
m := newTestManager()
var dismissCalled bool
var dismissContent string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, content string) error {
dismissCalled = true
dismissContent = content
return nil
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
}
m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if edited {
t.Fatal("expected preSend to return false for draft-based status")
}
if !dismissCalled {
t.Fatal("expected preSend to call SendDraft to dismiss the draft")
}
if dismissContent != "" {
t.Fatalf("expected empty dismiss content, got %q", dismissContent)
}
}
// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new
// typing stop function calls the previous stop first.
// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new
// typing stop function calls the previous stop first.
func TestRecordTypingStop_CleansUpOldEntry(t *testing.T) {
m := newTestManager()
var oldStopped atomic.Bool
m.RecordTypingStop("tg", "42", func() { oldStopped.Store(true) })
m.RecordTypingStop("tg", "42", func() {})
if !oldStopped.Load() {
t.Fatal("expected old typing stop to be called when new entry is recorded")
}
}
// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new
// reaction undo function calls the previous undo first.
// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new
// reaction undo function calls the previous undo first.
func TestRecordReactionUndo_CleansUpOldEntry(t *testing.T) {
m := newTestManager()
var oldUndone atomic.Bool
m.RecordReactionUndo("tg", "42", func() { oldUndone.Store(true) })
m.RecordReactionUndo("tg", "42", func() {})
if !oldUndone.Load() {
t.Fatal("expected old reaction undo to be called when new entry is recorded")
}
}
// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft
// in preSend also clears the statusEditTimes entry for that key, preventing
// stale throttle state from affecting the next processing cycle.
// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft
// in preSend also clears the statusEditTimes entry for that key, preventing
// stale throttle state from affecting the next processing cycle.
func TestPreSend_DraftDismiss_ClearsEditTimes(t *testing.T) {
m := newTestManager()
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error { return nil },
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
}
key := "test:123"
m.statusMsgIDs.Store(key, statusMsgEntry{draftID: 42, createdAt: time.Now()})
m.statusEditTimes.Store(key, time.Now())
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final"}
m.preSend(context.Background(), "test", msg, ch)
if _, loaded := m.statusEditTimes.Load(key); loaded {
t.Fatal("expected statusEditTimes to be cleared after draft dismiss")
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,14 +2,19 @@ package matrix
import ( import (
"context" "context"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"github.com/sipeed/picoclaw/pkg/config"
) )
func TestMatrixLocalpartMentionRegexp(t *testing.T) { func TestMatrixLocalpartMentionRegexp(t *testing.T) {
@ -194,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
} }
} }
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
const wantBody = "matrix-media-payload"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
t.Fatalf("unexpected download path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte(wantBody))
}))
defer server.Close()
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
ch := &MatrixChannel{client: client}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.png",
URL: id.ContentURIString("mxc://matrix.test/abc123"),
Info: &event.FileInfo{MimeType: "image/png"},
}
path, err := ch.downloadMedia(context.Background(), msg, "image")
if err != nil {
t.Fatalf("downloadMedia: %v", err)
}
defer os.Remove(path)
if ext := filepath.Ext(path); ext != ".png" {
t.Fatalf("temp file extension=%q want=.png", ext)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != wantBody {
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
}
}
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) { func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
ch := &MatrixChannel{} ch := &MatrixChannel{}
msg := &event.MessageEventContent{ msg := &event.MessageEventContent{
@ -289,3 +338,50 @@ func TestMatrixOutboundContent(t *testing.T) {
t.Fatalf("unexpected fallback body: %q", noCaption.Body) t.Fatalf("unexpected fallback body: %q", noCaption.Body)
} }
} }
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
name string
input string
contains string
}{
{"bold", "**hello**", "<strong>hello</strong>"},
{"italic", "_world_", "<em>world</em>"},
{"header", "### Title", "<h3"},
{"code block", "```\nfoo()\n```", "<code>"},
{"inline code", "`x`", "<code>x</code>"},
{"plain text", "just text", "just text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := markdownToHTML(tt.input)
if !strings.Contains(got, tt.contains) {
t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains)
}
})
}
}
func TestMessageContent(t *testing.T) {
richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
defaultt := &MatrixChannel{config: config.MatrixConfig{}}
for _, c := range []*MatrixChannel{richtext, defaultt} {
mc := c.messageContent("**hi**")
if mc.Format != event.FormatHTML {
t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format)
}
if !strings.Contains(mc.FormattedBody, "<strong>hi</strong>") {
t.Errorf("format %q: FormattedBody %q missing <strong>", c.config.MessageFormat, mc.FormattedBody)
}
if mc.Body != "**hi**" {
t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body)
}
}
mc := plain.messageContent("**hi**")
if mc.Format != "" || mc.FormattedBody != "" {
t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody)
}
}

View file

@ -0,0 +1,52 @@
package telegram
import (
"testing"
)
func TestParseChatID(t *testing.T) {
tests := []struct {
name string
input string
wantCID int64
wantTID int
wantErr bool
}{
{name: "plain private", input: "12345", wantCID: 12345, wantTID: 0},
{name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45},
{name: "trim spaces", input: " -100200/7 ", wantCID: -100200, wantTID: 7},
{name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0},
{name: "empty", input: "", wantErr: true},
{name: "bad chat", input: "abc/def", wantErr: true},
{name: "missing topic", input: "-100/", wantErr: true},
{name: "too many parts", input: "-100/1/2", wantErr: true},
{name: "negative topic", input: "-100/-1", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotCID, gotTID, err := parseChatID(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("parseChatID(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("parseChatID(%q) unexpected error: %v", tc.input, err)
}
if gotCID != tc.wantCID || gotTID != tc.wantTID {
t.Fatalf("parseChatID(%q) = (%d, %d), want (%d, %d)", tc.input, gotCID, gotTID, tc.wantCID, tc.wantTID)
}
})
}
}
func TestFormatChatID(t *testing.T) {
if got := formatChatID(-100, 42); got != "-100/42" {
t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42")
}
if got := formatChatID(12345, 0); got != "12345" {
t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345")
}
}

View file

@ -1,50 +1,462 @@
package telegram package telegram
import "testing" import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
func TestParseChatID(t *testing.T) { "github.com/mymmrac/telego"
tests := []struct { ta "github.com/mymmrac/telego/telegoapi"
name string "github.com/stretchr/testify/assert"
input string "github.com/stretchr/testify/require"
wantCID int64
wantTID int "github.com/sipeed/picoclaw/pkg/bus"
wantErr bool "github.com/sipeed/picoclaw/pkg/channels"
}{ )
{name: "plain private", input: "12345", wantCID: 12345, wantTID: 0},
{name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45}, const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
{name: "trim spaces", input: " -100200/7 ", wantCID: -100200, wantTID: 7},
{name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0}, // stubCaller implements ta.Caller for testing.
{name: "empty", input: "", wantErr: true}, type stubCaller struct {
{name: "bad chat", input: "abc/def", wantErr: true}, calls []stubCall
{name: "missing topic", input: "-100/", wantErr: true}, callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
{name: "too many parts", input: "-100/1/2", wantErr: true},
{name: "negative topic", input: "-100/-1", wantErr: true},
} }
for _, tc := range tests { type stubCall struct {
t.Run(tc.name, func(t *testing.T) { URL string
gotCID, gotTID, err := parseChatID(tc.input) Data *ta.RequestData
if tc.wantErr {
if err == nil {
t.Fatalf("parseChatID(%q) expected error, got nil", tc.input)
} }
return
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
s.calls = append(s.calls, stubCall{URL: url, Data: data})
return s.callFn(ctx, url, data)
} }
if err != nil {
t.Fatalf("parseChatID(%q) unexpected error: %v", tc.input, err) // stubConstructor implements ta.RequestConstructor for testing.
type stubConstructor struct{}
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
return &ta.RequestData{}, nil
} }
if gotCID != tc.wantCID || gotTID != tc.wantTID {
t.Fatalf("parseChatID(%q) = (%d, %d), want (%d, %d)", tc.input, gotCID, gotTID, tc.wantCID, tc.wantTID) func (s *stubConstructor) MultipartRequest(
parameters map[string]string,
files map[string]ta.NamedReader,
) (*ta.RequestData, error) {
return &ta.RequestData{}, nil
} }
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
func successResponse(t *testing.T) *ta.Response {
t.Helper()
msg := &telego.Message{MessageID: 1}
b, err := json.Marshal(msg)
require.NoError(t, err)
return &ta.Response{Ok: true, Result: b}
}
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
t.Helper()
bot, err := telego.NewBot(testToken,
telego.WithAPICaller(caller),
telego.WithRequestConstructor(&stubConstructor{}),
telego.WithDiscardLogger(),
)
require.NoError(t, err)
base := channels.NewBaseChannel("telegram", nil, nil, nil,
channels.WithMaxMessageLength(4000),
)
base.SetRunning(true)
return &TelegramChannel{
BaseChannel: base,
bot: bot,
chatIDs: make(map[string]int64),
}
}
func TestSend_EmptyContent(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("SendMessage should not be called for empty content")
return nil, nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
}) })
}
assert.NoError(t, err)
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
} }
func TestFormatChatID(t *testing.T) { func TestSend_ShortMessage_SingleCall(t *testing.T) {
if got := formatChatID(-100, 42); got != "-100/42" { caller := &stubCaller{
t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42") callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
} }
if got := formatChatID(12345, 0); got != "12345" { ch := newTestChannel(t, caller)
t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345")
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
} }
func TestSend_LongMessage_SingleCall(t *testing.T) {
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
// they reach Send(). A message at exactly 4000 chars should go through
// as a single SendMessage call (no re-split needed since HTML expansion
// won't exceed 4096 for plain text).
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
longContent := strings.Repeat("a", 4000)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
}
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
callCount := 0
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
callCount++
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
if callCount%2 == 1 {
return nil, errors.New("Bad Request: can't parse entities")
}
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
assert.NoError(t, err)
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
}
func TestSend_HTMLFallback_BothFail(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return nil, errors.New("send failed")
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
assert.Error(t, err)
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
}
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
// With a long message that gets split into 2 chunks, if both HTML and
// plain text fail on the first chunk, Send should return early.
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return nil, errors.New("send failed")
},
}
ch := newTestChannel(t, caller)
longContent := strings.Repeat("x", 4001)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
assert.Error(t, err)
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
}
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
// yields HTML that exceeds Telegram's limit while markdown stays within it.
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
htmlExpanded := markdownToTelegramHTML(markdownContent)
assert.Greater(
t, len([]rune(htmlExpanded)), 4096,
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
assert.NoError(t, err)
assert.Greater(
t, len(caller.calls), 1,
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
)
}
func TestSend_NotRunning(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("should not be called")
return nil, nil
},
}
ch := newTestChannel(t, caller)
ch.SetRunning(false)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
assert.ErrorIs(t, err, channels.ErrNotRunning)
assert.Empty(t, caller.calls)
}
func TestSend_InvalidChatID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("should not be called")
return nil, nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
assert.Error(t, err)
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
assert.Empty(t, caller.calls)
}
func TestParseTelegramChatID_Plain(t *testing.T) {
cid, tid, err := parseTelegramChatID("12345")
assert.NoError(t, err)
assert.Equal(t, int64(12345), cid)
assert.Equal(t, 0, tid)
}
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
cid, tid, err := parseTelegramChatID("-1001234567890")
assert.NoError(t, err)
assert.Equal(t, int64(-1001234567890), cid)
assert.Equal(t, 0, tid)
}
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
cid, tid, err := parseTelegramChatID("-1001234567890/42")
assert.NoError(t, err)
assert.Equal(t, int64(-1001234567890), cid)
assert.Equal(t, 42, tid)
}
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
cid, tid, err := parseTelegramChatID("-100123/1")
assert.NoError(t, err)
assert.Equal(t, int64(-100123), cid)
assert.Equal(t, 1, tid)
}
func TestParseTelegramChatID_Invalid(t *testing.T) {
_, _, err := parseTelegramChatID("not-a-number")
assert.Error(t, err)
}
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
_, _, err := parseTelegramChatID("-100123/not-a-thread")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid thread ID")
}
func TestSend_WithForumThreadID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1)
}
func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
Text: "hello from topic",
MessageID: 10,
MessageThreadID: 42,
Chat: telego.Chat{
ID: -1001234567890,
Type: "supergroup",
IsForum: true,
},
From: &telego.User{
ID: 7,
FirstName: "Alice",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok, "expected inbound message")
// Composite chatID should include thread ID
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
// Peer ID should include thread ID for session key isolation
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-1001234567890/42", inbound.Peer.ID)
// Parent peer metadata should be set for agent binding
assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"])
assert.Equal(t, "42", inbound.Metadata["parent_peer_id"])
}
func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
Text: "regular group message",
MessageID: 11,
Chat: telego.Chat{
ID: -100999,
Type: "group",
},
From: &telego.User{
ID: 8,
FirstName: "Bob",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok)
// Plain chatID without thread suffix
assert.Equal(t, "-100999", inbound.ChatID)
// Peer ID should be raw chat ID (no thread suffix)
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-100999", inbound.Peer.ID)
// No parent peer metadata
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
// In regular groups, reply threads set MessageThreadID to the original
// message ID. This should NOT trigger per-thread session isolation.
msg := &telego.Message{
Text: "reply in thread",
MessageID: 20,
MessageThreadID: 15,
Chat: telego.Chat{
ID: -100999,
Type: "supergroup",
IsForum: false,
},
From: &telego.User{
ID: 9,
FirstName: "Carol",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok)
// chatID should NOT include thread suffix for non-forum groups
assert.Equal(t, "-100999", inbound.ChatID)
// Peer ID should be raw chat ID (shared session for whole group)
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-100999", inbound.Peer.ID)
// No parent peer metadata
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
assert.Empty(t, inbound.Metadata["parent_peer_id"])
} }

View file

@ -209,7 +209,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
} }
}) })
t.Run("empty token skips verification", func(t *testing.T) { t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComAppConfig{ cfgEmpty := config.WeComAppConfig{
CorpID: "test_corp_id", CorpID: "test_corp_id",
CorpSecret: "test_secret", CorpSecret: "test_secret",
@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
} }
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should reject verification (fail-closed)")
} }
}) })
} }

View file

@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
} }
}) })
t.Run("empty token skips verification", func(t *testing.T) { t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
// Create a channel manually with empty token to test the behavior
cfgEmpty := config.WeComConfig{ cfgEmpty := config.WeComConfig{
Token: "", Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty, config: cfgEmpty,
} }
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should reject verification (fail-closed)")
} }
}) })
} }

View file

@ -31,7 +31,7 @@ func computeSignature(token, timestamp, nonce, encrypt string) string {
// This is a common function used by both WeCom Bot and WeCom App // This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" { if token == "" {
return true // Skip verification if token is not set return false
} }
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
} }

View file

@ -0,0 +1,123 @@
package config
import (
"encoding/json"
"testing"
)
func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "anthropic/claude-sonnet-4-6",
"plan_model_fallbacks": ["openai/gpt-4o"],
"max_tokens": 8192,
"max_tool_iterations": 20
}
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
}
if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
}
}
func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "main",
"plan_model": "anthropic/claude-sonnet-4-6"
},
{
"id": "advanced",
"plan_model": {
"primary": "anthropic/claude-opus-4",
"fallbacks": ["anthropic/claude-sonnet-4-6"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 2 {
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
}
main := cfg.Agents.List[0]
if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" {
t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel)
}
adv := cfg.Agents.List[1]
if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" {
t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel)
}
if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" {
t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks)
}
}
func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "default-plan-model",
"plan_model_fallbacks": ["default-fallback"],
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "custom",
"plan_model": {
"primary": "custom-plan-model",
"fallbacks": ["custom-fallback"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
custom := cfg.Agents.List[0]
if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" {
t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel)
}
if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" {
t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks)
}
if cfg.Agents.Defaults.PlanModel != "default-plan-model" {
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
}
}

View file

@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
if cfg.Tools.Web.Brave.MaxResults != 5 { if cfg.Tools.Web.Brave.MaxResults != 5 {
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
} }
if cfg.Tools.Web.Brave.APIKey != "" { if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
t.Error("Brave API key should be empty by default") t.Error("Brave API key should be empty by default")
} }
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
} }
} }
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
}
}
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") configPath := filepath.Join(dir, "config.json")
@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
} }
} }
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("tools.exec.allow_remote should remain true when unset in config file")
}
}
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") configPath := filepath.Join(dir, "config.json")
@ -416,133 +439,12 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
} }
} }
func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "anthropic/claude-sonnet-4-6",
"plan_model_fallbacks": ["openai/gpt-4o"],
"max_tokens": 8192,
"max_tool_iterations": 20
}
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
}
if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
}
}
func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "main",
"plan_model": "anthropic/claude-sonnet-4-6"
},
{
"id": "advanced",
"plan_model": {
"primary": "anthropic/claude-opus-4",
"fallbacks": ["anthropic/claude-sonnet-4-6"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 2 {
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
}
// String form
main := cfg.Agents.List[0]
if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" {
t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel)
}
// Object form with fallbacks
adv := cfg.Agents.List[1]
if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" {
t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel)
}
if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" {
t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks)
}
}
func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "default-plan-model",
"plan_model_fallbacks": ["default-fallback"],
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "custom",
"plan_model": {
"primary": "custom-plan-model",
"fallbacks": ["custom-fallback"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Agent-level plan_model should override defaults
custom := cfg.Agents.List[0]
if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" {
t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel)
}
if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" {
t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks)
}
// Defaults should still be intact
if cfg.Agents.Defaults.PlanModel != "default-plan-model" {
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
}
}
func TestLoadConfig_WebToolsProxy(t *testing.T) { func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json") configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{ configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}], "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}} "tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}` }`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
@ -603,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
} }
} }
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "English commas only",
input: "123,456,789",
expected: []string{"123", "456", "789"},
},
{
name: "Chinese commas only",
input: "123456789",
expected: []string{"123", "456", "789"},
},
{
name: "Mixed English and Chinese commas",
input: "123,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Single value",
input: "123",
expected: []string{"123"},
},
{
name: "Values with whitespace",
input: " 123 , 456 , 789 ",
expected: []string{"123", "456", "789"},
},
{
name: "Empty string",
input: "",
expected: nil,
},
{
name: "Only commas - English",
input: ",,",
expected: []string{},
},
{
name: "Only commas - Chinese",
input: "",
expected: []string{},
},
{
name: "Mixed commas with empty parts",
input: "123,,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Complex mixed values",
input: "user1@example.comuser2@test.com, admin@domain.org",
expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(tt.input))
if err != nil {
t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
}
if tt.expected == nil {
if f != nil {
t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
}
return
}
if len(f) != len(tt.expected) {
t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected))
return
}
for i, v := range tt.expected {
if f[i] != v {
t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
}
}
})
}
}
// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
t.Run("Empty string returns nil", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(""))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f != nil {
t.Errorf("Empty string should return nil, got %v", f)
}
})
t.Run("Commas only returns empty slice", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(",,,"))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f == nil {
t.Error("Commas only should return empty slice, not nil")
}
if len(f) != 0 {
t.Errorf("Expected empty slice, got %v", f)
}
})
}

View file

@ -413,6 +413,7 @@ func DefaultConfig() *Config {
Enabled: true, Enabled: true,
}, },
EnableDenyPatterns: true, EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60, TimeoutSeconds: 60,
}, },
Skills: SkillsToolsConfig{ Skills: SkillsToolsConfig{

View file

@ -45,7 +45,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
p := cfg.Providers p := cfg.Providers
result := make([]ModelConfig, 0, 20) var result []ModelConfig
// Track if we've applied the legacy model name fix (only for first provider) // Track if we've applied the legacy model name fix (only for first provider)
legacyModelNameApplied := false legacyModelNameApplied := false
@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
} }
return ModelConfig{ return ModelConfig{
ModelName: "openai", ModelName: "openai",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey, APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase, APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy, Proxy: p.OpenAI.Proxy,
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
} }
return ModelConfig{ return ModelConfig{
ModelName: "github-copilot", ModelName: "github-copilot",
Model: "github-copilot/gpt-5.2", Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase, APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode, ConnectMode: p.GitHubCopilot.ConnectMode,
}, true }, true

View file

@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
if result[0].ModelName != "openai" { if result[0].ModelName != "openai" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai") t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
} }
if result[0].Model != "openai/gpt-5.2" { if result[0].Model != "openai/gpt-5.4" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2") t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
} }
if result[0].APIKey != "sk-test-key" { if result[0].APIKey != "sk-test-key" {
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key") t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
@ -162,14 +162,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"}, Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"}, Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"}, Avian: ProviderConfig{APIKey: "key19"},
LongCat: ProviderConfig{APIKey: "key-longcat"},
}, },
} }
result := ConvertProvidersToModelList(cfg) result := ConvertProvidersToModelList(cfg)
// All 21 providers should be converted // All 22 providers should be converted
if len(result) != 21 { if len(result) != 22 {
t.Errorf("len(result) = %d, want 21", len(result)) t.Errorf("len(result) = %d, want 22", len(result))
} }
} }
@ -383,8 +384,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
for _, mc := range result { for _, mc := range result {
switch mc.ModelName { switch mc.ModelName {
case "openai": case "openai":
if mc.Model != "openai/gpt-5.2" { if mc.Model != "openai/gpt-5.4" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2") t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
} }
case "deepseek": case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" { if mc.Model != "deepseek/deepseek-reasoner" {
@ -557,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Tests for buildModelWithProtocol helper function // Tests for buildModelWithProtocol helper function
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.2") result := buildModelWithProtocol("openai", "gpt-5.4")
if result != "openai/gpt-5.2" { if result != "openai/gpt-5.4" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2") t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
} }
} }

View file

@ -0,0 +1,103 @@
package heartbeat
import (
"github.com/sipeed/picoclaw/pkg/tools"
"os"
"path/filepath"
"testing"
)
// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results
// do not trigger sendResponse (dedup: response is included in task status instead).
// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results
// do not trigger sendResponse (dedup: response is included in task status instead).
func TestExecuteHeartbeat_NoSendResponse(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return &tools.ToolResult{
ForUser: "Task result for user",
ForLLM: "Task result for LLM",
Silent: false,
IsError: false,
Async: false,
}
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
hs.mu.RLock()
notified := !hs.lastNotifiedAt.IsZero()
hs.mu.RUnlock()
if !notified {
t.Error("Expected lastNotifiedAt to be set after heartbeat completion")
}
}
func TestExecuteHeartbeat_TargetPriority_ExplicitTarget(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHeartbeatThreadID(77)
if err := hs.state.SetHeartbeatTarget("slack:C12345/999"); err != nil {
t.Fatalf("SetHeartbeatTarget failed: %v", err)
}
if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
var gotChannel, gotChatID string
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
gotChannel, gotChatID = channel, chatID
return tools.SilentResult("ok")
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
if gotChannel != "slack" || gotChatID != "C12345/999" {
t.Fatalf("handler target = %s:%s, want slack:C12345/999", gotChannel, gotChatID)
}
}
func TestExecuteHeartbeat_TargetPriority_TelegramThread(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHeartbeatThreadID(77)
if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
var gotChannel, gotChatID string
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
gotChannel, gotChatID = channel, chatID
return tools.SilentResult("ok")
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
if gotChannel != "telegram" || gotChatID != "-100500/77" {
t.Fatalf("handler target = %s:%s, want telegram:-100500/77", gotChannel, gotChatID)
}
}

View file

@ -184,42 +184,6 @@ func TestLogPath(t *testing.T) {
} }
} }
// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results
// do not trigger sendResponse (dedup: response is included in task status instead).
func TestExecuteHeartbeat_NoSendResponse(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
return &tools.ToolResult{
ForUser: "Task result for user",
ForLLM: "Task result for LLM",
Silent: false,
IsError: false,
Async: false,
}
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
// Execute heartbeat — since bus is nil, sendResponse would log but not crash.
// The key assertion is that lastNotifiedAt is still updated (flow reaches end).
hs.executeHeartbeat()
hs.mu.RLock()
notified := !hs.lastNotifiedAt.IsZero()
hs.mu.RUnlock()
if !notified {
t.Error("Expected lastNotifiedAt to be set after heartbeat completion")
}
}
// TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root // TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root
func TestHeartbeatFilePath(t *testing.T) { func TestHeartbeatFilePath(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
@ -239,62 +203,3 @@ func TestHeartbeatFilePath(t *testing.T) {
t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath) t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath)
} }
} }
func TestExecuteHeartbeat_TargetPriority_ExplicitTarget(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHeartbeatThreadID(77)
if err := hs.state.SetHeartbeatTarget("slack:C12345/999"); err != nil {
t.Fatalf("SetHeartbeatTarget failed: %v", err)
}
if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
var gotChannel, gotChatID string
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
gotChannel, gotChatID = channel, chatID
return tools.SilentResult("ok")
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
if gotChannel != "slack" || gotChatID != "C12345/999" {
t.Fatalf("handler target = %s:%s, want slack:C12345/999", gotChannel, gotChatID)
}
}
func TestExecuteHeartbeat_TargetPriority_TelegramThread(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs.stopChan = make(chan struct{})
hs.SetHeartbeatThreadID(77)
if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
var gotChannel, gotChatID string
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
gotChannel, gotChatID = channel, chatID
return tools.SilentResult("ok")
})
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644)
hs.executeHeartbeat()
if gotChannel != "telegram" || gotChatID != "-100500/77" {
t.Fatalf("handler target = %s:%s, want telegram:-100500/77", gotChannel, gotChatID)
}
}

View file

@ -0,0 +1,289 @@
package logger
import (
"testing"
"time"
)
func TestRingBuffer_PushAndRecent(t *testing.T) {
rb := newLogRingBuffer(5)
for i := 0; i < 3; i++ {
rb.push(LogEntry{Message: "msg" + string(rune('A'+i))})
}
got := rb.recent(0)
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
if got[0].Message != "msgA" || got[2].Message != "msgC" {
t.Errorf("unexpected order: %v", got)
}
}
func TestRingBuffer_Wrap(t *testing.T) {
rb := newLogRingBuffer(3)
for i := 0; i < 5; i++ {
rb.push(LogEntry{Message: string(rune('A' + i))})
}
got := rb.recent(0)
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
if got[0].Message != "C" || got[1].Message != "D" || got[2].Message != "E" {
t.Errorf("expected [C,D,E], got [%s,%s,%s]", got[0].Message, got[1].Message, got[2].Message)
}
}
func TestRingBuffer_RecentLimit(t *testing.T) {
rb := newLogRingBuffer(10)
for i := 0; i < 8; i++ {
rb.push(LogEntry{Message: string(rune('A' + i))})
}
got := rb.recent(3)
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
if got[0].Message != "F" || got[2].Message != "H" {
t.Errorf("expected last 3 entries, got %v", got)
}
}
func TestRecentLogs_FilterByLevel(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
DebugC("test", "debug msg")
InfoC("test", "info msg")
WarnC("test", "warn msg")
ErrorC("test", "error msg")
got := RecentLogs(WARN, "", 100)
for _, e := range got {
if e.Level == "DEBUG" || e.Level == "INFO" {
t.Errorf("unexpected level %s in result with minLevel=WARN", e.Level)
}
}
}
func TestRecentLogs_FilterByComponent(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoC("alpha", "from alpha")
InfoC("beta", "from beta")
InfoC("alpha", "another from alpha")
got := RecentLogs(DEBUG, "alpha", 100)
for _, e := range got {
if e.Component != "alpha" {
t.Errorf("unexpected component %s in result with component=alpha", e.Component)
}
}
}
func TestRecentLogs_CallerStripped(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoC("test", "caller test")
got := RecentLogs(DEBUG, "", 100)
for _, e := range got {
if e.Caller != "" {
t.Errorf("Caller should be stripped, got %q", e.Caller)
}
}
}
func TestSubscribe_ReceivesEntries(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
sub := Subscribe(nil)
defer Unsubscribe(sub)
InfoC("sub-test", "hello subscriber")
select {
case entry := <-sub.Ch:
if entry.Message != "hello subscriber" {
t.Errorf("expected 'hello subscriber', got %q", entry.Message)
}
case <-time.After(time.Second):
t.Error("timed out waiting for log entry")
}
}
func TestSubscribe_FilterApplied(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
sub := Subscribe(func(e LogEntry) bool {
return e.Component == "target"
})
defer Unsubscribe(sub)
InfoC("other", "should be filtered out")
InfoC("target", "should arrive")
select {
case entry := <-sub.Ch:
if entry.Component != "target" {
t.Errorf("expected component=target, got %q", entry.Component)
}
case <-time.After(time.Second):
t.Error("timed out waiting for filtered entry")
}
}
func TestUnsubscribe_ClosesChannel(t *testing.T) {
sub := Subscribe(nil)
Unsubscribe(sub)
_, ok := <-sub.Ch
if ok {
t.Error("expected channel to be closed after Unsubscribe")
}
}
func TestSanitizeFields(t *testing.T) {
tests := []struct {
name string
input map[string]any
maskedK []string // keys that should be "***"
safeK []string // keys that should keep original value
}{
{
name: "nil fields",
input: nil,
maskedK: nil,
},
{
name: "empty fields",
input: map[string]any{},
maskedK: nil,
},
{
name: "sensitive keys masked",
input: map[string]any{
"token": "abc123",
"api_key": "sk-xxx",
"secret": "s3cr3t",
"password": "pass",
"authorization": "Bearer tok",
},
maskedK: []string{"token", "api_key", "secret", "password", "authorization"},
},
{
name: "case insensitive",
input: map[string]any{
"Token": "abc",
"API_KEY": "xyz",
"Secret": "s",
"PASSWORD": "p",
"Authorization": "a",
"Credential": "c",
},
maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"},
},
{
name: "safe keys preserved",
input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
safeK: []string{"error", "count", "user_id", "component"},
},
{
name: "mixed keys",
input: map[string]any{
"token": "sensitive",
"msg_signature": "safe",
"corp_secret": "sensitive2",
"nonce": "safe2",
},
maskedK: []string{"token", "corp_secret"},
safeK: []string{"msg_signature", "nonce"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeFields(tt.input)
for _, k := range tt.maskedK {
if v, ok := result[k]; !ok || v != "***" {
t.Errorf("expected key %q to be masked, got %v", k, v)
}
}
for _, k := range tt.safeK {
if result[k] != tt.input[k] {
t.Errorf("expected key %q to be preserved as %v, got %v", k, tt.input[k], result[k])
}
}
})
}
}
func TestSanitizeFieldsDoesNotMutateOriginal(t *testing.T) {
original := map[string]any{"token": "secret_value", "name": "test"}
_ = SanitizeFields(original)
if original["token"] != "secret_value" {
t.Error("SanitizeFields should not mutate the original map")
}
}
func TestRecentLogsSanitizesFields(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoCF("sanitize-test", "log with sensitive fields", map[string]any{
"token": "my-secret-token",
"api_key": "sk-12345",
"user_id": "safe-value",
})
got := RecentLogs(DEBUG, "sanitize-test", 100)
if len(got) == 0 {
t.Fatal("expected at least one log entry")
}
last := got[len(got)-1]
if last.Fields["token"] != "***" {
t.Errorf("expected token to be masked, got %v", last.Fields["token"])
}
if last.Fields["api_key"] != "***" {
t.Errorf("expected api_key to be masked, got %v", last.Fields["api_key"])
}
if last.Fields["user_id"] != "safe-value" {
t.Errorf("expected user_id to be preserved, got %v", last.Fields["user_id"])
}
}
func TestParseLevel(t *testing.T) {
tests := []struct {
input string
want LogLevel
}{
{"debug", DEBUG},
{"DEBUG", DEBUG},
{"info", INFO},
{"WARN", WARN},
{"error", ERROR},
{"fatal", FATAL},
{"unknown", INFO},
{"", INFO},
}
for _, tt := range tests {
got := ParseLevel(tt.input)
if got != tt.want {
t.Errorf("ParseLevel(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}

View file

@ -2,7 +2,6 @@ package logger
import ( import (
"testing" "testing"
"time"
) )
func TestLogLevelFiltering(t *testing.T) { func TestLogLevelFiltering(t *testing.T) {
@ -138,289 +137,3 @@ func TestLoggerHelperFunctions(t *testing.T) {
DebugC("test", "Debug with component") DebugC("test", "Debug with component")
WarnF("Warning with fields", map[string]any{"key": "value"}) WarnF("Warning with fields", map[string]any{"key": "value"})
} }
// ── Ring buffer tests ──
func TestRingBuffer_PushAndRecent(t *testing.T) {
rb := newLogRingBuffer(5)
for i := 0; i < 3; i++ {
rb.push(LogEntry{Message: "msg" + string(rune('A'+i))})
}
got := rb.recent(0)
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
if got[0].Message != "msgA" || got[2].Message != "msgC" {
t.Errorf("unexpected order: %v", got)
}
}
func TestRingBuffer_Wrap(t *testing.T) {
rb := newLogRingBuffer(3)
for i := 0; i < 5; i++ {
rb.push(LogEntry{Message: string(rune('A' + i))})
}
got := rb.recent(0)
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
// Should have C, D, E (oldest two dropped)
if got[0].Message != "C" || got[1].Message != "D" || got[2].Message != "E" {
t.Errorf("expected [C,D,E], got [%s,%s,%s]", got[0].Message, got[1].Message, got[2].Message)
}
}
func TestRingBuffer_RecentLimit(t *testing.T) {
rb := newLogRingBuffer(10)
for i := 0; i < 8; i++ {
rb.push(LogEntry{Message: string(rune('A' + i))})
}
got := rb.recent(3)
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
if got[0].Message != "F" || got[2].Message != "H" {
t.Errorf("expected last 3 entries, got %v", got)
}
}
func TestRecentLogs_FilterByLevel(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
// Log messages at different levels
DebugC("test", "debug msg")
InfoC("test", "info msg")
WarnC("test", "warn msg")
ErrorC("test", "error msg")
got := RecentLogs(WARN, "", 100)
for _, e := range got {
if e.Level == "DEBUG" || e.Level == "INFO" {
t.Errorf("unexpected level %s in result with minLevel=WARN", e.Level)
}
}
}
func TestRecentLogs_FilterByComponent(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoC("alpha", "from alpha")
InfoC("beta", "from beta")
InfoC("alpha", "another from alpha")
got := RecentLogs(DEBUG, "alpha", 100)
for _, e := range got {
if e.Component != "alpha" {
t.Errorf("unexpected component %s in result with component=alpha", e.Component)
}
}
}
func TestRecentLogs_CallerStripped(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoC("test", "caller test")
got := RecentLogs(DEBUG, "", 100)
for _, e := range got {
if e.Caller != "" {
t.Errorf("Caller should be stripped, got %q", e.Caller)
}
}
}
func TestSubscribe_ReceivesEntries(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
sub := Subscribe(nil)
defer Unsubscribe(sub)
InfoC("sub-test", "hello subscriber")
select {
case entry := <-sub.Ch:
if entry.Message != "hello subscriber" {
t.Errorf("expected 'hello subscriber', got %q", entry.Message)
}
case <-time.After(time.Second):
t.Error("timed out waiting for log entry")
}
}
func TestSubscribe_FilterApplied(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
sub := Subscribe(func(e LogEntry) bool {
return e.Component == "target"
})
defer Unsubscribe(sub)
InfoC("other", "should be filtered out")
InfoC("target", "should arrive")
select {
case entry := <-sub.Ch:
if entry.Component != "target" {
t.Errorf("expected component=target, got %q", entry.Component)
}
case <-time.After(time.Second):
t.Error("timed out waiting for filtered entry")
}
}
func TestUnsubscribe_ClosesChannel(t *testing.T) {
sub := Subscribe(nil)
Unsubscribe(sub)
_, ok := <-sub.Ch
if ok {
t.Error("expected channel to be closed after Unsubscribe")
}
}
func TestSanitizeFields(t *testing.T) {
tests := []struct {
name string
input map[string]any
maskedK []string // keys that should be "***"
safeK []string // keys that should keep original value
}{
{
name: "nil fields",
input: nil,
maskedK: nil,
},
{
name: "empty fields",
input: map[string]any{},
maskedK: nil,
},
{
name: "sensitive keys masked",
input: map[string]any{
"token": "abc123",
"api_key": "sk-xxx",
"secret": "s3cr3t",
"password": "pass",
"authorization": "Bearer tok",
},
maskedK: []string{"token", "api_key", "secret", "password", "authorization"},
},
{
name: "case insensitive",
input: map[string]any{
"Token": "abc",
"API_KEY": "xyz",
"Secret": "s",
"PASSWORD": "p",
"Authorization": "a",
"Credential": "c",
},
maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"},
},
{
name: "safe keys preserved",
input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
safeK: []string{"error", "count", "user_id", "component"},
},
{
name: "mixed keys",
input: map[string]any{
"token": "sensitive",
"msg_signature": "safe",
"corp_secret": "sensitive2",
"nonce": "safe2",
},
maskedK: []string{"token", "corp_secret"},
safeK: []string{"msg_signature", "nonce"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeFields(tt.input)
for _, k := range tt.maskedK {
if v, ok := result[k]; !ok || v != "***" {
t.Errorf("expected key %q to be masked, got %v", k, v)
}
}
for _, k := range tt.safeK {
if result[k] != tt.input[k] {
t.Errorf("expected key %q to be preserved as %v, got %v", k, tt.input[k], result[k])
}
}
})
}
}
func TestSanitizeFieldsDoesNotMutateOriginal(t *testing.T) {
original := map[string]any{"token": "secret_value", "name": "test"}
_ = SanitizeFields(original)
if original["token"] != "secret_value" {
t.Error("SanitizeFields should not mutate the original map")
}
}
func TestRecentLogsSanitizesFields(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoCF("sanitize-test", "log with sensitive fields", map[string]any{
"token": "my-secret-token",
"api_key": "sk-12345",
"user_id": "safe-value",
})
got := RecentLogs(DEBUG, "sanitize-test", 100)
if len(got) == 0 {
t.Fatal("expected at least one log entry")
}
last := got[len(got)-1]
if last.Fields["token"] != "***" {
t.Errorf("expected token to be masked, got %v", last.Fields["token"])
}
if last.Fields["api_key"] != "***" {
t.Errorf("expected api_key to be masked, got %v", last.Fields["api_key"])
}
if last.Fields["user_id"] != "safe-value" {
t.Errorf("expected user_id to be preserved, got %v", last.Fields["user_id"])
}
}
func TestParseLevel(t *testing.T) {
tests := []struct {
input string
want LogLevel
}{
{"debug", DEBUG},
{"DEBUG", DEBUG},
{"info", INFO},
{"WARN", WARN},
{"error", ERROR},
{"fatal", FATAL},
{"unknown", INFO},
{"", INFO},
}
for _, tt := range tests {
got := ParseLevel(tt.input)
if got != tt.want {
t.Errorf("ParseLevel(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}

View file

@ -382,3 +382,55 @@ func TestMigrateFromJSON_NonexistentDir(t *testing.T) {
t.Errorf("expected 0, got %d", count) t.Errorf("expected 0, got %d", count)
} }
} }
func TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) {
sessionsDir := t.TempDir()
store, err := NewJSONLStore(sessionsDir)
if err != nil {
t.Fatalf("NewJSONLStore: %v", err)
}
ctx := context.Background()
if addErr := store.AddMessage(ctx, "agent:main:pico:direct:pico:test", "user", "keep me"); addErr != nil {
t.Fatalf("AddMessage: %v", addErr)
}
if summaryErr := store.SetSummary(ctx, "agent:main:pico:direct:pico:test", "keep summary"); summaryErr != nil {
t.Fatalf("SetSummary: %v", summaryErr)
}
metaPath := filepath.Join(sessionsDir, "agent_main_pico_direct_pico_test.meta.json")
if _, statErr := os.Stat(metaPath); statErr != nil {
t.Fatalf("meta file missing before migration: %v", statErr)
}
count, err := MigrateFromJSON(ctx, sessionsDir, store)
if err != nil {
t.Fatalf("MigrateFromJSON: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 migrated, got %d", count)
}
history, err := store.GetHistory(ctx, "agent:main:pico:direct:pico:test")
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history) != 1 || history[0].Content != "keep me" {
t.Fatalf("history = %+v, want preserved single message", history)
}
summary, err := store.GetSummary(ctx, "agent:main:pico:direct:pico:test")
if err != nil {
t.Fatalf("GetSummary: %v", err)
}
if summary != "keep summary" {
t.Fatalf("summary = %q, want %q", summary, "keep summary")
}
if _, statErr := os.Stat(metaPath); statErr != nil {
t.Fatalf("meta file should remain in place: %v", statErr)
}
if _, statErr := os.Stat(metaPath + ".migrated"); !os.IsNotExist(statErr) {
t.Fatalf("meta file should not be renamed, stat err = %v", statErr)
}
}

View file

@ -1107,6 +1107,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
Exec: config.ExecConfig{ Exec: config.ExecConfig{
EnableDenyPatterns: c.Exec.EnableDenyPatterns, EnableDenyPatterns: c.Exec.EnableDenyPatterns,
CustomDenyPatterns: c.Exec.CustomDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns,
AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote,
}, },
} }
} }

View file

@ -290,6 +290,20 @@ func TestConvertToPicoClaw(t *testing.T) {
} }
} }
func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) {
cfg := (&PicoClawConfig{
Tools: ToolsConfig{
Exec: ExecConfig{
EnableDenyPatterns: true,
},
},
}).ToStandardConfig()
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true")
}
}
func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "openclaw.json") configPath := filepath.Join(tmpDir, "openclaw.json")

View file

@ -9,8 +9,6 @@ import (
"github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option" anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
func TestBuildParams_BasicMessage(t *testing.T) { func TestBuildParams_BasicMessage(t *testing.T) {
@ -86,13 +84,13 @@ func TestBuildParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get weather for a city", Description: "Get weather for a city",
Parameters: protocoltypes.MustMarshalParameters(map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"city": map[string]any{"type": "string"}, "city": map[string]any{"type": "string"},
}, },
"required": []any{"city"}, "required": []any{"city"},
}), },
}, },
}, },
} }

View file

@ -12,7 +12,7 @@ func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
ID: "call_read_file_123", ID: "call_read_file_123",
Function: &FunctionCall{ Function: &FunctionCall{
Name: "read_file", Name: "read_file",
Arguments: map[string]any{"path": "README.md"}, Arguments: `{"path":"README.md"}`,
}, },
}}, }},
}, },

View file

@ -0,0 +1,283 @@
package providers
import (
"strings"
"testing"
)
func TestExtractXMLToolCalls_Single(t *testing.T) {
text := `<vendor:toolcall>
<invoke name="exec">
<parameter name="command">echo hello</parameter>
</invoke>
</vendor:toolcall>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "exec" {
t.Errorf("Name = %q, want %q", calls[0].Name, "exec")
}
if calls[0].Arguments["command"] != "echo hello" {
t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "echo hello")
}
if calls[0].Function == nil || calls[0].Function.Name != "exec" {
t.Errorf("Function.Name should be exec")
}
}
func TestExtractXMLToolCalls_Multiple(t *testing.T) {
text := `<vendor:toolcall>
<invoke name="web_search">
<parameter name="query">golang testing</parameter>
</invoke>
<invoke name="exec">
<parameter name="command">go test ./...</parameter>
<parameter name="timeout">30</parameter>
</invoke>
</vendor:toolcall>`
calls := extractXMLToolCalls(text)
if len(calls) != 2 {
t.Fatalf("expected 2 tool calls, got %d", len(calls))
}
if calls[0].Name != "web_search" {
t.Errorf("[0].Name = %q, want %q", calls[0].Name, "web_search")
}
if calls[1].Name != "exec" {
t.Errorf("[1].Name = %q, want %q", calls[1].Name, "exec")
}
if calls[1].Arguments["timeout"] != "30" {
t.Errorf("[1].Arguments[timeout] = %v, want %q", calls[1].Arguments["timeout"], "30")
}
}
func TestExtractXMLToolCalls_NoXML(t *testing.T) {
calls := extractXMLToolCalls("just regular text")
if len(calls) != 0 {
t.Errorf("expected 0 tool calls, got %d", len(calls))
}
}
func TestStripXMLToolCalls(t *testing.T) {
text := `Let me run that.
<vendor:toolcall>
<invoke name="exec">
<parameter name="command">echo hello</parameter>
</invoke>
</vendor:toolcall>
Done.`
got := stripXMLToolCalls(text)
if strings.Contains(got, "toolcall") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "Let me run that.") {
t.Errorf("should keep text before, got %q", got)
}
if !strings.Contains(got, "Done.") {
t.Errorf("should keep text after, got %q", got)
}
}
func TestExtractXMLToolCalls_MismatchedCloseTag(t *testing.T) {
text := `<minimax:toolcall>
<invoke name="readfile">
<parameter name="path">/home/user/project/pyproject.toml</parameter>
</invoke>
</minimax:tool_call>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "readfile" {
t.Errorf("Name = %q, want %q", calls[0].Name, "readfile")
}
if calls[0].Arguments["path"] != "/home/user/project/pyproject.toml" {
t.Errorf("Arguments[path] = %v, want pyproject.toml path", calls[0].Arguments["path"])
}
}
func TestStripXMLToolCalls_MismatchedCloseTag(t *testing.T) {
text := `今テスト走らせるね。` +
`
<minimax:toolcall>
<invoke name="exec">
<parameter name="command">cd /home/user && pytest</parameter>
</invoke>
</minimax:tool_call>`
got := stripXMLToolCalls(text)
if strings.Contains(got, "toolcall") || strings.Contains(got, "tool_call") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "今テスト走らせるね。") {
t.Errorf("should keep text before, got %q", got)
}
}
func TestExtractXMLToolCalls_UnderscoreOpenTag(t *testing.T) {
text := `<minimax:tool_call>
<invoke name="exec">
<parameter name="command">ls -la</parameter>
</invoke>
</minimax:tool_call>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "exec" {
t.Errorf("Name = %q, want %q", calls[0].Name, "exec")
}
if calls[0].Arguments["command"] != "ls -la" {
t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "ls -la")
}
}
func TestExtractXMLToolCalls_HyphenTag(t *testing.T) {
text := `<vendor:Tool-Call>
<invoke name="read_file">
<parameter name="path">/etc/hosts</parameter>
</invoke>
</vendor:tool-call>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Errorf("Name = %q, want %q", calls[0].Name, "read_file")
}
}
func TestStripXMLToolCalls_UnderscoreOpenTag(t *testing.T) {
text := `Here is the result.
<minimax:tool_call>
<invoke name="exec">
<parameter name="command">ls</parameter>
</invoke>
</minimax:toolcall>
Finished.`
got := stripXMLToolCalls(text)
if strings.Contains(got, "tool_call") || strings.Contains(got, "toolcall") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "Here is the result.") {
t.Errorf("should keep text before, got %q", got)
}
if !strings.Contains(got, "Finished.") {
t.Errorf("should keep text after, got %q", got)
}
}
func TestExtractXMLToolCalls_OrphanedClosingTag(t *testing.T) {
text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user/workspace</parameter>\n</invoke>\n</minimax:tool_call>"
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "listdir" {
t.Errorf("Name = %q, want %q", calls[0].Name, "listdir")
}
if calls[0].Arguments["path"] != "/home/user/workspace" {
t.Errorf("Arguments[path] = %v, want /home/user/workspace", calls[0].Arguments["path"])
}
}
func TestStripXMLToolCalls_OrphanedClosingTag(t *testing.T) {
text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user</parameter>\n</invoke>\n</minimax:tool_call>"
got := stripXMLToolCalls(text)
if strings.Contains(got, "invoke") || strings.Contains(got, "TOOLCALL") || strings.Contains(got, "minimax") {
t.Errorf("should remove orphaned closing tag block, got %q", got)
}
if !strings.Contains(got, "了解") {
t.Errorf("should keep user-facing text, got %q", got)
}
}
func TestStripXMLToolCalls_NoXML(t *testing.T) {
text := "Just regular text."
got := stripXMLToolCalls(text)
if got != text {
t.Errorf("stripXMLToolCalls() = %q, want %q", got, text)
}
}
func TestNormalizeAlpha(t *testing.T) {
tests := []struct {
input, want string
}{
{"toolcall", "toolcall"},
{"tool_call", "toolcall"},
{"Tool-Call", "toolcall"},
{"ReadFile", "readfile"},
{"read_file", "readfile"},
{"EXEC", "exec"},
{"web123search", "websearch"},
{"", ""},
}
for _, tt := range tests {
got := normalizeAlpha(tt.input)
if got != tt.want {
t.Errorf("normalizeAlpha(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestLevenshtein(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"toolcall", "toolcall", 0},
{"toolcall", "tool_call", 1},
{"toolcall", "tool-call", 1},
{"toolcall", "ToolCall", 2},
{"kitten", "sitting", 3},
}
for _, tt := range tests {
got := levenshtein(tt.a, tt.b)
if got != tt.want {
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
func TestIsToolCallTag(t *testing.T) {
for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} {
if !isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = false, want true", name)
}
}
for _, name := range []string{"function_call", "FunctionCall", "functioncall", "FUNCTION_CALL"} {
if !isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = false, want true", name)
}
}
for _, name := range []string{"tool_use", "ToolUse", "tooluse", "TOOL_USE"} {
if !isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = false, want true", name)
}
}
for _, name := range []string{"invoke", "parameter", "function", "result", "hello", "content"} {
if isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = true, want false", name)
}
}
}

View file

@ -619,12 +619,12 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get weather for a location", Description: "Get weather for a location",
Parameters: MustMarshalParameters(map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"location": map[string]any{"type": "string"}, "location": map[string]any{"type": "string"},
}, },
}), },
}, },
}, },
} }
@ -917,9 +917,9 @@ func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
if got[0].Arguments["name"] != "test" { if got[0].Arguments["name"] != "test" {
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
} }
// Verify parsed arguments are also set on FunctionCall // Verify raw arguments string is preserved in FunctionCall
if len(got[0].Function.Arguments) == 0 { if got[0].Function.Arguments == "" {
t.Error("Function.Arguments should contain parsed JSON arguments") t.Error("Function.Arguments should contain raw JSON string")
} }
} }
@ -984,282 +984,3 @@ func TestFindMatchingBrace(t *testing.T) {
} }
} }
} }
// --- XML tool call extract/strip tests ---
func TestExtractXMLToolCalls_Single(t *testing.T) {
text := `<vendor:toolcall>
<invoke name="exec">
<parameter name="command">echo hello</parameter>
</invoke>
</vendor:toolcall>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "exec" {
t.Errorf("Name = %q, want %q", calls[0].Name, "exec")
}
if calls[0].Arguments["command"] != "echo hello" {
t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "echo hello")
}
if calls[0].Function == nil || calls[0].Function.Name != "exec" {
t.Errorf("Function.Name should be exec")
}
}
func TestExtractXMLToolCalls_Multiple(t *testing.T) {
text := `<vendor:toolcall>
<invoke name="web_search">
<parameter name="query">golang testing</parameter>
</invoke>
<invoke name="exec">
<parameter name="command">go test ./...</parameter>
<parameter name="timeout">30</parameter>
</invoke>
</vendor:toolcall>`
calls := extractXMLToolCalls(text)
if len(calls) != 2 {
t.Fatalf("expected 2 tool calls, got %d", len(calls))
}
if calls[0].Name != "web_search" {
t.Errorf("[0].Name = %q, want %q", calls[0].Name, "web_search")
}
if calls[1].Name != "exec" {
t.Errorf("[1].Name = %q, want %q", calls[1].Name, "exec")
}
if calls[1].Arguments["timeout"] != "30" {
t.Errorf("[1].Arguments[timeout] = %v, want %q", calls[1].Arguments["timeout"], "30")
}
}
func TestExtractXMLToolCalls_NoXML(t *testing.T) {
calls := extractXMLToolCalls("just regular text")
if len(calls) != 0 {
t.Errorf("expected 0 tool calls, got %d", len(calls))
}
}
func TestStripXMLToolCalls(t *testing.T) {
text := `Let me run that.
<vendor:toolcall>
<invoke name="exec">
<parameter name="command">echo hello</parameter>
</invoke>
</vendor:toolcall>
Done.`
got := stripXMLToolCalls(text)
if strings.Contains(got, "toolcall") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "Let me run that.") {
t.Errorf("should keep text before, got %q", got)
}
if !strings.Contains(got, "Done.") {
t.Errorf("should keep text after, got %q", got)
}
}
func TestExtractXMLToolCalls_MismatchedCloseTag(t *testing.T) {
// MiniMax uses <minimax:toolcall> but closes with </minimax:tool_call> (underscore)
text := `<minimax:toolcall>
<invoke name="readfile">
<parameter name="path">/home/user/project/pyproject.toml</parameter>
</invoke>
</minimax:tool_call>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "readfile" {
t.Errorf("Name = %q, want %q", calls[0].Name, "readfile")
}
if calls[0].Arguments["path"] != "/home/user/project/pyproject.toml" {
t.Errorf("Arguments[path] = %v, want pyproject.toml path", calls[0].Arguments["path"])
}
}
func TestStripXMLToolCalls_MismatchedCloseTag(t *testing.T) {
text := `今テスト走らせるね。` + //nolint:gosmopolitan // CJK test data
`
<minimax:toolcall>
<invoke name="exec">
<parameter name="command">cd /home/user && pytest</parameter>
</invoke>
</minimax:tool_call>`
got := stripXMLToolCalls(text)
if strings.Contains(got, "toolcall") || strings.Contains(got, "tool_call") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "今テスト走らせるね。") { //nolint:gosmopolitan // CJK test data
t.Errorf("should keep text before, got %q", got)
}
}
func TestExtractXMLToolCalls_UnderscoreOpenTag(t *testing.T) {
// Opening tag also uses underscore: <minimax:tool_call>
text := `<minimax:tool_call>
<invoke name="exec">
<parameter name="command">ls -la</parameter>
</invoke>
</minimax:tool_call>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "exec" {
t.Errorf("Name = %q, want %q", calls[0].Name, "exec")
}
if calls[0].Arguments["command"] != "ls -la" {
t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "ls -la")
}
}
func TestExtractXMLToolCalls_HyphenTag(t *testing.T) {
// Hypothetical: <vendor:Tool-Call>
text := `<vendor:Tool-Call>
<invoke name="read_file">
<parameter name="path">/etc/hosts</parameter>
</invoke>
</vendor:tool-call>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Errorf("Name = %q, want %q", calls[0].Name, "read_file")
}
}
func TestStripXMLToolCalls_UnderscoreOpenTag(t *testing.T) {
text := `Here is the result.
<minimax:tool_call>
<invoke name="exec">
<parameter name="command">ls</parameter>
</invoke>
</minimax:toolcall>
Finished.`
got := stripXMLToolCalls(text)
if strings.Contains(got, "tool_call") || strings.Contains(got, "toolcall") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "Here is the result.") {
t.Errorf("should keep text before, got %q", got)
}
if !strings.Contains(got, "Finished.") {
t.Errorf("should keep text after, got %q", got)
}
}
func TestExtractXMLToolCalls_OrphanedClosingTag(t *testing.T) {
// LLM emits [TOOLCALL] marker + <invoke> with orphaned closing tag (no opening tag)
text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user/workspace</parameter>\n</invoke>\n</minimax:tool_call>" //nolint:gosmopolitan // CJK test data
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "listdir" {
t.Errorf("Name = %q, want %q", calls[0].Name, "listdir")
}
if calls[0].Arguments["path"] != "/home/user/workspace" {
t.Errorf("Arguments[path] = %v, want /home/user/workspace", calls[0].Arguments["path"])
}
}
func TestStripXMLToolCalls_OrphanedClosingTag(t *testing.T) {
text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user</parameter>\n</invoke>\n</minimax:tool_call>" //nolint:gosmopolitan // CJK test data
got := stripXMLToolCalls(text)
if strings.Contains(got, "invoke") || strings.Contains(got, "TOOLCALL") || strings.Contains(got, "minimax") {
t.Errorf("should remove orphaned closing tag block, got %q", got)
}
if !strings.Contains(got, "了解") { //nolint:gosmopolitan // CJK test data
t.Errorf("should keep user-facing text, got %q", got)
}
}
func TestStripXMLToolCalls_NoXML(t *testing.T) {
text := "Just regular text."
got := stripXMLToolCalls(text)
if got != text {
t.Errorf("stripXMLToolCalls() = %q, want %q", got, text)
}
}
func TestNormalizeAlpha(t *testing.T) {
tests := []struct {
input, want string
}{
{"toolcall", "toolcall"},
{"tool_call", "toolcall"},
{"Tool-Call", "toolcall"},
{"ReadFile", "readfile"},
{"read_file", "readfile"},
{"EXEC", "exec"},
{"web123search", "websearch"},
{"", ""},
}
for _, tt := range tests {
got := normalizeAlpha(tt.input)
if got != tt.want {
t.Errorf("normalizeAlpha(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestLevenshtein(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"toolcall", "toolcall", 0},
{"toolcall", "tool_call", 1},
{"toolcall", "tool-call", 1},
{"toolcall", "ToolCall", 2}, // T and C
{"kitten", "sitting", 3},
}
for _, tt := range tests {
got := levenshtein(tt.a, tt.b)
if got != tt.want {
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
func TestIsToolCallTag(t *testing.T) {
// Should match — toolcall variants
for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} {
if !isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = false, want true", name)
}
}
// Should match — function_call variants
for _, name := range []string{"function_call", "FunctionCall", "functioncall", "FUNCTION_CALL"} {
if !isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = false, want true", name)
}
}
// Should match — tool_use variants
for _, name := range []string{"tool_use", "ToolUse", "tooluse", "TOOL_USE"} {
if !isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = false, want true", name)
}
}
// Should NOT match
for _, name := range []string{"invoke", "parameter", "function", "result", "hello", "content"} {
if isToolCallTag(name) {
t.Errorf("isToolCallTag(%q) = true, want false", name)
}
}
}

View file

@ -76,8 +76,8 @@ func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {
if resp.ToolCalls[0].ID != "call_1" { if resp.ToolCalls[0].ID != "call_1" {
t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
} }
if resp.ToolCalls[0].Function.Arguments["path"] != "/tmp/test.txt" { if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` {
t.Errorf("ToolCalls[0].Function.Arguments[path] = %v", resp.ToolCalls[0].Function.Arguments["path"]) t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments)
} }
// Content should have the tool call JSON stripped // Content should have the tool call JSON stripped
if strings.Contains(resp.Content, "tool_calls") { if strings.Contains(resp.Content, "tool_calls") {
@ -292,12 +292,12 @@ func TestBuildPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get current weather", Description: "Get current weather",
Parameters: MustMarshalParameters(map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"city": map[string]any{"type": "string"}, "city": map[string]any{"type": "string"},
}, },
}), },
}, },
}, },
} }
@ -490,7 +490,7 @@ echo '{"type":"turn.completed"}'`
} }
messages := []Message{{Role: "user", Content: "test"}} messages := []Message{{Role: "user", Content: "test"}}
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) _, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil)
if err != nil { if err != nil {
t.Fatalf("Chat() error: %v", err) t.Fatalf("Chat() error: %v", err)
} }
@ -502,7 +502,7 @@ echo '{"type":"turn.completed"}'`
} }
args := string(argsData) args := string(argsData)
if !strings.Contains(args, "-m gpt-5.2-codex") { if !strings.Contains(args, "-m gpt-5.3-codex") {
t.Errorf("args should contain model flag, got: %s", args) t.Errorf("args should contain model flag, got: %s", args)
} }
if !strings.Contains(args, "-C /tmp/test-workspace") { if !strings.Contains(args, "-C /tmp/test-workspace") {

View file

@ -79,7 +79,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
Type: "function", Type: "function",
Function: &FunctionCall{ Function: &FunctionCall{
Name: "read_file", Name: "read_file",
Arguments: map[string]any{"path": "README.md"}, Arguments: `{"path":"README.md"}`,
}, },
}, },
}, },
@ -114,12 +114,12 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get weather", Description: "Get weather",
Parameters: MustMarshalParameters(map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"city": map[string]any{"type": "string"}, "city": map[string]any{"type": "string"},
}, },
}), },
}, },
}, },
} }
@ -166,9 +166,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "web_search", Name: "web_search",
Description: "local web search", Description: "local web search",
Parameters: MustMarshalParameters(map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
}), },
}, },
}, },
{ {
@ -176,9 +176,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "read_file", Name: "read_file",
Description: "read file", Description: "read file",
Parameters: MustMarshalParameters(map[string]any{ Parameters: map[string]any{
"type": "object", "type": "object",
}), },
}, },
}, },
} }
@ -568,7 +568,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T)
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
messages := []Message{{Role: "user", Content: "Hello"}} messages := []Message{{Role: "user", Content: "Hello"}}
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil) resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil)
if err != nil { if err != nil {
t.Fatalf("Chat() error: %v", err) t.Fatalf("Chat() error: %v", err)
} }
@ -599,7 +599,7 @@ func TestResolveCodexModel(t *testing.T) {
wantFallback: true, wantFallback: true,
}, },
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, {name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false},
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
} }

View file

@ -0,0 +1,73 @@
package providers
import (
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"testing"
)
func TestCreateProviderByName_OpenAI_OAuth(t *testing.T) {
originalGetCredential := getCredential
t.Cleanup(func() { getCredential = originalGetCredential })
getCredential = func(provider string) (*auth.AuthCredential, error) {
if provider != "openai" {
t.Fatalf("provider = %q, want openai", provider)
}
return &auth.AuthCredential{
AccessToken: "openai-token",
AccountID: "acct_test",
}, nil
}
cfg := config.DefaultConfig()
cfg.Providers.OpenAI.AuthMethod = "oauth"
provider, err := CreateProviderByName(cfg, "openai")
if err != nil {
t.Fatalf("CreateProviderByName() error = %v", err)
}
if _, ok := provider.(*CodexProvider); !ok {
t.Fatalf("provider type = %T, want *CodexProvider", provider)
}
}
func TestCreateProviderByName_VLLM(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Providers.VLLM.APIKey = "test-vllm-key"
cfg.Providers.VLLM.APIBase = "https://api.example.com/v1"
provider, err := CreateProviderByName(cfg, "vllm")
if err != nil {
t.Fatalf("CreateProviderByName() error = %v", err)
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("provider type = %T, want *HTTPProvider", provider)
}
}
func TestCreateProviderByName_Unknown(t *testing.T) {
cfg := config.DefaultConfig()
_, err := CreateProviderByName(cfg, "nonexistent-provider")
if err == nil {
t.Fatal("expected error for unknown provider, got nil")
}
}
func TestCreateProviderByName_CaseInsensitive(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Providers.VLLM.APIKey = "test-key"
cfg.Providers.VLLM.APIBase = "https://example.com/v1"
provider, err := CreateProviderByName(cfg, "VLLM")
if err != nil {
t.Fatalf("CreateProviderByName() error = %v", err)
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("provider type = %T, want *HTTPProvider", provider)
}
}

View file

@ -113,6 +113,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"}, {"vllm", "vllm"},
{"deepseek", "deepseek"}, {"deepseek", "deepseek"},
{"ollama", "ollama"}, {"ollama", "ollama"},
{"longcat", "longcat"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -162,6 +163,29 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
} }
} }
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: "test-key",
APIBase: "https://api.longcat.chat/openai",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "LongCat-Flash-Thinking" {
t.Errorf("modelID = %q, want %q", modelID, "LongCat-Flash-Thinking")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) { func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-anthropic", ModelName: "test-anthropic",

View file

@ -178,6 +178,26 @@ func TestResolveProviderSelection(t *testing.T) {
wantAPIBase: "https://api.moonshot.cn/v1", wantAPIBase: "https://api.moonshot.cn/v1",
wantProxy: "http://127.0.0.1:7890", wantProxy: "http://127.0.0.1:7890",
}, },
{
name: "explicit longcat provider uses defaults",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "longcat"
cfg.Providers.LongCat.APIKey = "longcat-key"
cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.longcat.chat/openai",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "longcat model fallback uses longcat base default",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking"
cfg.Providers.LongCat.APIKey = "longcat-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "https://api.longcat.chat/openai",
},
{ {
name: "missing keys returns model config error", name: "missing keys returns model config error",
setup: func(cfg *config.Config) { setup: func(cfg *config.Config) {
@ -329,69 +349,3 @@ func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {
// which is not yet implemented in the new factory_provider.go // which is not yet implemented in the new factory_provider.go
t.Skip("OpenAI OAuth via model_list not yet implemented") t.Skip("OpenAI OAuth via model_list not yet implemented")
} }
func TestCreateProviderByName_OpenAI_OAuth(t *testing.T) {
originalGetCredential := getCredential
t.Cleanup(func() { getCredential = originalGetCredential })
getCredential = func(provider string) (*auth.AuthCredential, error) {
if provider != "openai" {
t.Fatalf("provider = %q, want openai", provider)
}
return &auth.AuthCredential{
AccessToken: "openai-token",
AccountID: "acct_test",
}, nil
}
cfg := config.DefaultConfig()
cfg.Providers.OpenAI.AuthMethod = "oauth"
provider, err := CreateProviderByName(cfg, "openai")
if err != nil {
t.Fatalf("CreateProviderByName() error = %v", err)
}
if _, ok := provider.(*CodexProvider); !ok {
t.Fatalf("provider type = %T, want *CodexProvider", provider)
}
}
func TestCreateProviderByName_VLLM(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Providers.VLLM.APIKey = "test-vllm-key"
cfg.Providers.VLLM.APIBase = "https://api.example.com/v1"
provider, err := CreateProviderByName(cfg, "vllm")
if err != nil {
t.Fatalf("CreateProviderByName() error = %v", err)
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("provider type = %T, want *HTTPProvider", provider)
}
}
func TestCreateProviderByName_Unknown(t *testing.T) {
cfg := config.DefaultConfig()
_, err := CreateProviderByName(cfg, "nonexistent-provider")
if err == nil {
t.Fatal("expected error for unknown provider, got nil")
}
}
func TestCreateProviderByName_CaseInsensitive(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Providers.VLLM.APIKey = "test-key"
cfg.Providers.VLLM.APIBase = "https://example.com/v1"
provider, err := CreateProviderByName(cfg, "VLLM")
if err != nil {
t.Fatalf("CreateProviderByName() error = %v", err)
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("provider type = %T, want *HTTPProvider", provider)
}
}

View file

@ -0,0 +1,456 @@
package openai_compat
import (
"context"
"encoding/json"
"fmt"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
tests := []struct {
name string
input string
wantModel string
}{
{
name: "strips groq prefix and keeps nested model",
input: "groq/openai/gpt-oss-120b",
wantModel: "openai/gpt-oss-120b",
},
{
name: "strips ollama prefix",
input: "ollama/qwen2.5:14b",
wantModel: "qwen2.5:14b",
},
{
name: "strips deepseek prefix",
input: "deepseek/deepseek-chat",
wantModel: "deepseek-chat",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if requestBody["model"] != tt.wantModel {
t.Fatalf("model = %v, want %s", requestBody["model"], tt.wantModel)
}
})
}
}
func TestNormalizeModel_OpenAIPrefix(t *testing.T) {
if got := normalizeModel("openai/gpt-5.2", "https://api.openai.com/v1"); got != "gpt-5.2" {
t.Fatalf("normalizeModel(openai/gpt-5.2) = %q, want %q", got, "gpt-5.2")
}
}
func TestProviderChat_StreamingTextResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/text/chatcompletion_v2" {
http.Error(w, "not found", http.StatusNotFound)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if body["stream"] != true {
t.Error("expected stream=true in request body")
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w)
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "",
WithEndpointPath("/text/chatcompletion_v2"),
WithStream(true),
)
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "MiniMax-M1", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if out.Content != "Hello world" {
t.Fatalf("Content = %q, want %q", out.Content, "Hello world")
}
if out.FinishReason != "stop" {
t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "stop")
}
if out.Usage == nil || out.Usage.TotalTokens != 7 {
t.Fatalf("Usage.TotalTokens = %v, want 7", out.Usage)
}
}
func TestProviderChat_StreamingToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\"}"}}]},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w)
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "test", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
}
tc := out.ToolCalls[0]
if tc.ID != "call_1" {
t.Fatalf("ToolCalls[0].ID = %q, want %q", tc.ID, "call_1")
}
if tc.Name != "get_weather" {
t.Fatalf("ToolCalls[0].Name = %q, want %q", tc.Name, "get_weather")
}
if tc.Arguments["city"] != "SF" {
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", tc.Arguments["city"])
}
}
func TestProviderChat_CustomEndpointPath(t *testing.T) {
var hitPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitPath = r.URL.Path
resp := map[string]any{
"choices": []map[string]any{
{"message": map[string]any{"content": "ok"}, "finish_reason": "stop"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "",
WithEndpointPath("/text/chatcompletion_v2"),
)
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if hitPath != "/text/chatcompletion_v2" {
t.Fatalf("endpoint path = %q, want %q", hitPath, "/text/chatcompletion_v2")
}
}
func TestReadSSEIntoChannel_TextAndToolCalls(t *testing.T) {
sseData := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"greet","arguments":"{\"n"}}]},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ame\":\"Bob\"}"}}]},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`,
``,
`data: [DONE]`,
``,
}, "\n")
ch := make(chan protocoltypes.StreamEvent, 32)
go func() {
defer close(ch)
readSSEIntoChannel(context.Background(), strings.NewReader(sseData), ch)
}()
var events []protocoltypes.StreamEvent
for ev := range ch {
events = append(events, ev)
}
if len(events) < 3 {
t.Fatalf("got %d events, want at least 3", len(events))
}
if events[0].ContentDelta != "Hello" {
t.Errorf("events[0].ContentDelta = %q, want %q", events[0].ContentDelta, "Hello")
}
if events[1].ContentDelta != " world" {
t.Errorf("events[1].ContentDelta = %q, want %q", events[1].ContentDelta, " world")
}
if len(events[2].ToolCallDeltas) != 1 || events[2].ToolCallDeltas[0].ID != "call_1" {
t.Errorf("events[2] should contain tool call with ID=call_1")
}
if events[2].ToolCallDeltas[0].Name != "greet" {
t.Errorf("events[2].ToolCallDeltas[0].Name = %q, want %q", events[2].ToolCallDeltas[0].Name, "greet")
}
lastEv := events[len(events)-1]
if lastEv.FinishReason != "stop" {
t.Errorf("last event FinishReason = %q, want %q", lastEv.FinishReason, "stop")
}
if lastEv.Usage == nil || lastEv.Usage.TotalTokens != 7 {
t.Errorf("last event Usage.TotalTokens = %v, want 7", lastEv.Usage)
}
}
func TestReadSSEIntoChannel_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
sseData := `data: {"choices":[{"delta":{"content":"first"},"finish_reason":""}]}` + "\n\n"
ch := make(chan protocoltypes.StreamEvent, 32)
go func() {
defer close(ch)
readSSEIntoChannel(ctx, strings.NewReader(sseData), ch)
}()
ev := <-ch
if ev.ContentDelta != "first" {
t.Fatalf("ContentDelta = %q, want %q", ev.ContentDelta, "first")
}
cancel()
_, ok := <-ch
if ok {
t.Fatal("expected channel to be closed after context cancel")
}
}
func TestAccumulateStream_FullResponse(t *testing.T) {
ch := make(chan protocoltypes.StreamEvent, 8)
go func() {
ch <- protocoltypes.StreamEvent{ContentDelta: "Hello"}
ch <- protocoltypes.StreamEvent{ContentDelta: " world"}
ch <- protocoltypes.StreamEvent{
ToolCallDeltas: []protocoltypes.StreamToolCallDelta{
{Index: 0, ID: "call_1", Name: "test_tool", ArgumentsDelta: `{"key"`},
},
}
ch <- protocoltypes.StreamEvent{
ToolCallDeltas: []protocoltypes.StreamToolCallDelta{
{Index: 0, ArgumentsDelta: `:"value"}`},
},
}
ch <- protocoltypes.StreamEvent{
FinishReason: "stop",
Usage: &UsageInfo{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8},
}
close(ch)
}()
resp, err := AccumulateStream(ch)
if err != nil {
t.Fatalf("AccumulateStream() error = %v", err)
}
if resp.Content != "Hello world" {
t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 8 {
t.Errorf("Usage.TotalTokens = %v, want 8", resp.Usage)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls))
}
if resp.ToolCalls[0].Name != "test_tool" {
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_tool")
}
if resp.ToolCalls[0].Arguments["key"] != "value" {
t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "value")
}
}
func TestAccumulateStream_Error(t *testing.T) {
ch := make(chan protocoltypes.StreamEvent, 4)
go func() {
ch <- protocoltypes.StreamEvent{ContentDelta: "partial"}
ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("connection reset")}
close(ch)
}()
_, err := AccumulateStream(ch)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "connection reset") {
t.Fatalf("error = %q, want to contain %q", err.Error(), "connection reset")
}
}
func TestChatStream_EndToEnd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"content":"stream"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"content":"ed"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w)
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
ch, err := p.ChatStream(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
resp, err := AccumulateStream(ch)
if err != nil {
t.Fatalf("AccumulateStream() error = %v", err)
}
if resp.Content != "streamed" {
t.Errorf("Content = %q, want %q", resp.Content, "streamed")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
t.Errorf("Usage.TotalTokens = %v, want 3", resp.Usage)
}
}
func TestChatStream_EarlyCancel(t *testing.T) {
serverDone := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(serverDone)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
for i := 0; i < 1000; i++ {
select {
case <-r.Context().Done():
return
default:
}
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"},\"finish_reason\":\"\"}]}\n\n")
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := p.ChatStream(ctx, []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
count := 0
for ev := range ch {
if ev.Err != nil {
break
}
count++
if count >= 5 {
cancel()
}
}
if count < 5 {
t.Errorf("expected at least 5 events before cancel, got %d", count)
}
<-serverDone
}
func TestCanStream(t *testing.T) {
p1 := NewProvider("key", "https://example.com", "")
if p1.CanStream() {
t.Error("CanStream() = true for non-stream provider")
}
p2 := NewProvider("key", "https://example.com", "", WithStream(true))
if !p2.CanStream() {
t.Error("CanStream() = false for stream provider")
}
}
func TestProvider_RequestTimeoutNonPositive(t *testing.T) {
p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", -1)
if p.httpClient.Timeout != defaultRequestTimeout {
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
}
}

View file

@ -1,9 +1,10 @@
package openai_compat package openai_compat
import ( import (
"context" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -107,6 +108,55 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
} }
} }
func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{
"content": "",
"tool_calls": []map[string]any{
{
"id": "call_1",
"type": "function",
"function": map[string]any{
"name": "get_weather",
"arguments": map[string]any{
"city": "SF",
"metric": true,
},
},
},
},
},
"finish_reason": "tool_calls",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
}
if out.ToolCalls[0].Name != "get_weather" {
t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
}
if out.ToolCalls[0].Arguments["city"] != "SF" {
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
}
if out.ToolCalls[0].Arguments["metric"] != true {
t.Fatalf("ToolCalls[0].Arguments[metric] = %v, want true", out.ToolCalls[0].Arguments["metric"])
}
}
func TestProviderChat_ParsesReasoningContent(t *testing.T) { func TestProviderChat_ParsesReasoningContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{ resp := map[string]any{
@ -151,6 +201,56 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) {
} }
} }
func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
// Simulate a multi-turn conversation where the assistant's previous
// reply included reasoning_content (e.g. from kimi-k2.5).
messages := []Message{
{Role: "user", Content: "What is 1+1?"},
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"},
{Role: "user", Content: "What about 2+2?"},
}
_, err := p.Chat(t.Context(), messages, nil, "kimi-k2.5", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
// Verify reasoning_content is preserved in the serialized request.
reqMessages, ok := requestBody["messages"].([]any)
if !ok {
t.Fatalf("messages is not []any: %T", requestBody["messages"])
}
assistantMsg, ok := reqMessages[1].(map[string]any)
if !ok {
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
}
if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" {
t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"])
}
}
func TestProviderChat_HTTPError(t *testing.T) { func TestProviderChat_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@ -164,6 +264,132 @@ func TestProviderChat_HTTPError(t *testing.T) {
} }
} }
func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"bad request"}`))
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "Status: 400") {
t.Fatalf("expected status code in error, got %v", err)
}
if strings.Contains(err.Error(), "returned HTML instead of JSON") {
t.Fatalf("expected non-HTML http error, got %v", err)
}
}
func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
tests := []struct {
name string
contentType string
statusCode int
body string
}{
{
name: "html success response",
contentType: "text/html; charset=utf-8",
statusCode: http.StatusOK,
body: "<!DOCTYPE html><html><body>gateway login</body></html>",
},
{
name: "html error response",
contentType: "text/html; charset=utf-8",
statusCode: http.StatusBadGateway,
body: "<!DOCTYPE html><html><body>bad gateway</body></html>",
},
{
name: "mislabeled html success response",
contentType: "application/json",
statusCode: http.StatusOK,
body: " \r\n\t<!DOCTYPE html><html><body>gateway login</body></html>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", tt.contentType)
w.WriteHeader(tt.statusCode)
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), fmt.Sprintf("Status: %d", tt.statusCode)) {
t.Fatalf("expected status code in error, got %v", err)
}
if !strings.Contains(err.Error(), "returned HTML instead of JSON") {
t.Fatalf("expected helpful HTML error, got %v", err)
}
if !strings.Contains(err.Error(), "check api_base or proxy configuration") {
t.Fatalf("expected configuration hint, got %v", err)
}
})
}
}
func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) {
content := strings.Repeat("a", 1024)
body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}`
p := NewProvider("key", "https://example.com/v1", "")
p.httpClient = &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: &errAfterDataReadCloser{
data: []byte(body),
chunkSize: 64,
},
}, nil
}),
}
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if out.Content != content {
t.Fatalf("Content = %q, want %q", out.Content, content)
}
}
func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) {
body := append([]byte("<!DOCTYPE html><html><body>"), bytes.Repeat([]byte("A"), 2048)...)
body = append(body, []byte("</body></html>")...)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write(body)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "Body: <!DOCTYPE html><html><body>") {
t.Fatalf("expected html preview in error, got %v", err)
}
if !strings.Contains(err.Error(), "...") {
t.Fatalf("expected truncated preview, got %v", err)
}
}
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
var requestBody map[string]any var requestBody map[string]any
@ -205,12 +431,17 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
} }
} }
func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
wantModel string wantModel string
}{ }{
{
name: "strips litellm prefix and preserves proxy model name",
input: "litellm/my-proxy-alias",
wantModel: "my-proxy-alias",
},
{ {
name: "strips groq prefix and keeps nested model", name: "strips groq prefix and keeps nested model",
input: "groq/openai/gpt-oss-120b", input: "groq/openai/gpt-oss-120b",
@ -226,6 +457,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
input: "deepseek/deepseek-chat", input: "deepseek/deepseek-chat",
wantModel: "deepseek-chat", wantModel: "deepseek-chat",
}, },
{
name: "strips vivgrid prefix",
input: "vivgrid/auto",
wantModel: "auto",
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -330,393 +566,11 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
} }
if got := normalizeModel("vivgrid/managed", "https://api.vivgrid.com/v1"); got != "managed" {
t.Fatalf("normalizeModel(vivgrid) = %q, want %q", got, "managed")
} }
if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" {
func TestNormalizeModel_OpenAIPrefix(t *testing.T) { t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto")
if got := normalizeModel("openai/gpt-5.2", "https://api.openai.com/v1"); got != "gpt-5.2" {
t.Fatalf("normalizeModel(openai/gpt-5.2) = %q, want %q", got, "gpt-5.2")
}
}
func TestProviderChat_StreamingTextResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/text/chatcompletion_v2" {
http.Error(w, "not found", http.StatusNotFound)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if body["stream"] != true {
t.Error("expected stream=true in request body")
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w) // blank line between events
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "",
WithEndpointPath("/text/chatcompletion_v2"),
WithStream(true),
)
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "MiniMax-M1", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if out.Content != "Hello world" {
t.Fatalf("Content = %q, want %q", out.Content, "Hello world")
}
if out.FinishReason != "stop" {
t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "stop")
}
if out.Usage == nil || out.Usage.TotalTokens != 7 {
t.Fatalf("Usage.TotalTokens = %v, want 7", out.Usage)
}
}
func TestProviderChat_StreamingToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\"}"}}]},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w)
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "test", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
}
tc := out.ToolCalls[0]
if tc.ID != "call_1" {
t.Fatalf("ToolCalls[0].ID = %q, want %q", tc.ID, "call_1")
}
if tc.Name != "get_weather" {
t.Fatalf("ToolCalls[0].Name = %q, want %q", tc.Name, "get_weather")
}
if tc.Arguments["city"] != "SF" {
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", tc.Arguments["city"])
}
}
func TestProviderChat_CustomEndpointPath(t *testing.T) {
var hitPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitPath = r.URL.Path
resp := map[string]any{
"choices": []map[string]any{
{"message": map[string]any{"content": "ok"}, "finish_reason": "stop"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "",
WithEndpointPath("/text/chatcompletion_v2"),
)
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if hitPath != "/text/chatcompletion_v2" {
t.Fatalf("endpoint path = %q, want %q", hitPath, "/text/chatcompletion_v2")
}
}
func TestReadSSEIntoChannel_TextAndToolCalls(t *testing.T) {
sseData := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"greet","arguments":"{\"n"}}]},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ame\":\"Bob\"}"}}]},"finish_reason":""}]}`,
``,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`,
``,
`data: [DONE]`,
``,
}, "\n")
ch := make(chan protocoltypes.StreamEvent, 32)
go func() {
defer close(ch)
readSSEIntoChannel(context.Background(), strings.NewReader(sseData), ch)
}()
var events []protocoltypes.StreamEvent
for ev := range ch {
events = append(events, ev)
}
if len(events) < 3 {
t.Fatalf("got %d events, want at least 3", len(events))
}
// Check content deltas
if events[0].ContentDelta != "Hello" {
t.Errorf("events[0].ContentDelta = %q, want %q", events[0].ContentDelta, "Hello")
}
if events[1].ContentDelta != " world" {
t.Errorf("events[1].ContentDelta = %q, want %q", events[1].ContentDelta, " world")
}
// Check tool call deltas
if len(events[2].ToolCallDeltas) != 1 || events[2].ToolCallDeltas[0].ID != "call_1" {
t.Errorf("events[2] should contain tool call with ID=call_1")
}
if events[2].ToolCallDeltas[0].Name != "greet" {
t.Errorf("events[2].ToolCallDeltas[0].Name = %q, want %q", events[2].ToolCallDeltas[0].Name, "greet")
}
// Check finish event
lastEv := events[len(events)-1]
if lastEv.FinishReason != "stop" {
t.Errorf("last event FinishReason = %q, want %q", lastEv.FinishReason, "stop")
}
if lastEv.Usage == nil || lastEv.Usage.TotalTokens != 7 {
t.Errorf("last event Usage.TotalTokens = %v, want 7", lastEv.Usage)
}
}
func TestReadSSEIntoChannel_ContextCancel(t *testing.T) {
// Simulate a slow SSE stream that gets canceled.
ctx, cancel := context.WithCancel(context.Background())
// Create a reader that blocks after sending one chunk.
sseData := `data: {"choices":[{"delta":{"content":"first"},"finish_reason":""}]}` + "\n\n"
ch := make(chan protocoltypes.StreamEvent, 32)
go func() {
defer close(ch)
readSSEIntoChannel(ctx, strings.NewReader(sseData), ch)
}()
// Read the first event.
ev := <-ch
if ev.ContentDelta != "first" {
t.Fatalf("ContentDelta = %q, want %q", ev.ContentDelta, "first")
}
// Cancel the context; the channel should close.
cancel()
_, ok := <-ch
if ok {
t.Fatal("expected channel to be closed after context cancel")
}
}
func TestAccumulateStream_FullResponse(t *testing.T) {
ch := make(chan protocoltypes.StreamEvent, 8)
go func() {
ch <- protocoltypes.StreamEvent{ContentDelta: "Hello"}
ch <- protocoltypes.StreamEvent{ContentDelta: " world"}
ch <- protocoltypes.StreamEvent{
ToolCallDeltas: []protocoltypes.StreamToolCallDelta{
{Index: 0, ID: "call_1", Name: "test_tool", ArgumentsDelta: `{"key"`},
},
}
ch <- protocoltypes.StreamEvent{
ToolCallDeltas: []protocoltypes.StreamToolCallDelta{
{Index: 0, ArgumentsDelta: `:"value"}`},
},
}
ch <- protocoltypes.StreamEvent{
FinishReason: "stop",
Usage: &UsageInfo{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8},
}
close(ch)
}()
resp, err := AccumulateStream(ch)
if err != nil {
t.Fatalf("AccumulateStream() error = %v", err)
}
if resp.Content != "Hello world" {
t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 8 {
t.Errorf("Usage.TotalTokens = %v, want 8", resp.Usage)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls))
}
if resp.ToolCalls[0].Name != "test_tool" {
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_tool")
}
if resp.ToolCalls[0].Arguments["key"] != "value" {
t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "value")
}
}
func TestAccumulateStream_Error(t *testing.T) {
ch := make(chan protocoltypes.StreamEvent, 4)
go func() {
ch <- protocoltypes.StreamEvent{ContentDelta: "partial"}
ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("connection reset")}
close(ch)
}()
_, err := AccumulateStream(ch)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "connection reset") {
t.Fatalf("error = %q, want to contain %q", err.Error(), "connection reset")
}
}
func TestChatStream_EndToEnd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunks := []string{
`data: {"choices":[{"delta":{"content":"stream"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{"content":"ed"},"finish_reason":""}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`,
`data: [DONE]`,
}
for _, c := range chunks {
fmt.Fprintln(w, c)
fmt.Fprintln(w)
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
ch, err := p.ChatStream(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
resp, err := AccumulateStream(ch)
if err != nil {
t.Fatalf("AccumulateStream() error = %v", err)
}
if resp.Content != "streamed" {
t.Errorf("Content = %q, want %q", resp.Content, "streamed")
}
if resp.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
}
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
t.Errorf("Usage.TotalTokens = %v, want 3", resp.Usage)
}
}
func TestChatStream_EarlyCancel(t *testing.T) {
serverDone := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(serverDone)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
// Send many chunks; expect the client to cancel early.
for i := 0; i < 1000; i++ {
select {
case <-r.Context().Done():
return
default:
}
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"},\"finish_reason\":\"\"}]}\n\n")
if flusher != nil {
flusher.Flush()
}
}
}))
defer server.Close()
p := NewProvider("key", server.URL, "", WithStream(true))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := p.ChatStream(ctx, []Message{{Role: "user", Content: "hi"}}, nil, "test", nil)
if err != nil {
t.Fatalf("ChatStream() error = %v", err)
}
// Read a few events, then cancel.
count := 0
for ev := range ch {
if ev.Err != nil {
break
}
count++
if count >= 5 {
cancel()
}
}
if count < 5 {
t.Errorf("expected at least 5 events before cancel, got %d", count)
}
// Server should have received the cancellation.
<-serverDone
}
func TestCanStream(t *testing.T) {
p1 := NewProvider("key", "https://example.com", "")
if p1.CanStream() {
t.Error("CanStream() = true for non-stream provider")
}
p2 := NewProvider("key", "https://example.com", "", WithStream(true))
if !p2.CanStream() {
t.Error("CanStream() = false for stream provider")
} }
} }
@ -734,11 +588,38 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) {
} }
} }
func TestProvider_RequestTimeoutNonPositive(t *testing.T) { type roundTripperFunc func(*http.Request) (*http.Response, error)
p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", -1)
if p.httpClient.Timeout != defaultRequestTimeout { func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) return f(r)
} }
type errAfterDataReadCloser struct {
data []byte
chunkSize int
offset int
}
func (r *errAfterDataReadCloser) Read(p []byte) (int, error) {
if r.offset >= len(r.data) {
return 0, io.ErrUnexpectedEOF
}
n := r.chunkSize
if n <= 0 || n > len(p) {
n = len(p)
}
remaining := len(r.data) - r.offset
if n > remaining {
n = remaining
}
copy(p, r.data[r.offset:r.offset+n])
r.offset += n
return n, nil
}
func (r *errAfterDataReadCloser) Close() error {
return nil
} }
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) { func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
@ -761,3 +642,202 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
} }
} }
func TestSerializeMessages_PlainText(t *testing.T) {
messages := []protocoltypes.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi", ReasoningContent: "thinking..."},
}
result := serializeMessages(messages)
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
var msgs []map[string]any
json.Unmarshal(data, &msgs)
if msgs[0]["content"] != "hello" {
t.Fatalf("expected plain string content, got %v", msgs[0]["content"])
}
if msgs[1]["reasoning_content"] != "thinking..." {
t.Fatalf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"])
}
}
func TestSerializeMessages_WithMedia(t *testing.T) {
messages := []protocoltypes.Message{
{Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}},
}
result := serializeMessages(messages)
data, _ := json.Marshal(result)
var msgs []map[string]any
json.Unmarshal(data, &msgs)
content, ok := msgs[0]["content"].([]any)
if !ok {
t.Fatalf("expected array content for media message, got %T", msgs[0]["content"])
}
if len(content) != 2 {
t.Fatalf("expected 2 content parts, got %d", len(content))
}
textPart := content[0].(map[string]any)
if textPart["type"] != "text" || textPart["text"] != "describe this" {
t.Fatalf("text part mismatch: %v", textPart)
}
imgPart := content[1].(map[string]any)
if imgPart["type"] != "image_url" {
t.Fatalf("expected image_url type, got %v", imgPart["type"])
}
imgURL := imgPart["image_url"].(map[string]any)
if imgURL["url"] != "data:image/png;base64,abc123" {
t.Fatalf("image url mismatch: %v", imgURL["url"])
}
}
func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
messages := []protocoltypes.Message{
{Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},
}
result := serializeMessages(messages)
data, _ := json.Marshal(result)
var msgs []map[string]any
json.Unmarshal(data, &msgs)
if msgs[0]["tool_call_id"] != "call_1" {
t.Fatalf("tool_call_id not preserved with media, got %v", msgs[0]["tool_call_id"])
}
// Content should be multipart array
if _, ok := msgs[0]["content"].([]any); !ok {
t.Fatalf("expected array content, got %T", msgs[0]["content"])
}
}
// chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key,
// and returns the decoded request body for assertion.
func chatWithCacheKey(t *testing.T, apiBase string) map[string]any {
t.Helper()
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
p.apiBase = apiBase
p.httpClient = &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
r.URL, _ = url.Parse(server.URL + r.URL.Path)
return http.DefaultTransport.RoundTrip(r)
}),
}
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
nil,
"test-model",
map[string]any{"prompt_cache_key": "agent-main"},
)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
return requestBody
}
func TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) {
body := chatWithCacheKey(t, "https://api.openai.com/v1")
if body["prompt_cache_key"] != "agent-main" {
t.Fatalf("prompt_cache_key = %v, want %q", body["prompt_cache_key"], "agent-main")
}
}
func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) {
tests := []struct {
name string
apiBase string
}{
{"mistral", "https://api.mistral.ai/v1"},
{"gemini", "https://generativelanguage.googleapis.com/v1beta"},
{"deepseek", "https://api.deepseek.com/v1"},
{"groq", "https://api.groq.com/openai/v1"},
{"minimax", "https://api.minimaxi.com/v1"},
{"ollama_local", "http://localhost:11434/v1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := chatWithCacheKey(t, tt.apiBase)
if _, exists := body["prompt_cache_key"]; exists {
t.Fatalf("prompt_cache_key should NOT be sent to %s, but was included in request", tt.name)
}
})
}
}
func TestSupportsPromptCacheKey(t *testing.T) {
tests := []struct {
apiBase string
want bool
}{
{"https://api.openai.com/v1", true},
{"https://api.openai.com/v1/", true},
{"https://myresource.openai.azure.com/openai/deployments/gpt-4", true},
{"https://eastus.openai.azure.com/v1", true},
{"https://api.mistral.ai/v1", false},
{"https://generativelanguage.googleapis.com/v1beta", false},
{"https://api.deepseek.com/v1", false},
{"https://api.groq.com/openai/v1", false},
{"http://localhost:11434/v1", false},
{"https://openrouter.ai/api/v1", false},
// Edge cases: proxy URLs with openai.com in path should NOT match
{"https://my-proxy.com/api.openai.com/v1", false},
{"https://proxy.example.com/openai.azure.com/v1", false},
// Malformed or empty
{"", false},
{"not-a-url", false},
}
for _, tt := range tests {
if got := supportsPromptCacheKey(tt.apiBase); got != tt.want {
t.Errorf("supportsPromptCacheKey(%q) = %v, want %v", tt.apiBase, got, tt.want)
}
}
}
func TestSerializeMessages_StripsSystemParts(t *testing.T) {
messages := []protocoltypes.Message{
{
Role: "system",
Content: "you are helpful",
SystemParts: []protocoltypes.ContentBlock{
{Type: "text", Text: "you are helpful"},
},
},
}
result := serializeMessages(messages)
data, _ := json.Marshal(result)
raw := string(data)
if strings.Contains(raw, "system_parts") {
t.Fatal("system_parts should not appear in serialized output")
}
}

View file

@ -208,7 +208,10 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
// so loadSessions still maps back to the right in-memory key. // so loadSessions still maps back to the right in-memory key.
func sanitizeFilename(key string) string { func sanitizeFilename(key string) string {
return strings.ReplaceAll(key, ":", "_") s := strings.ReplaceAll(key, ":", "_")
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, "\\", "_")
return s
} }
func (sm *SessionManager) Save(key string) error { func (sm *SessionManager) Save(key string) error {

View file

@ -0,0 +1,121 @@
package session
import (
"github.com/sipeed/picoclaw/pkg/providers"
"testing"
)
func TestSanitizeHistory_OrphanedToolCall(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
{ID: "call_2", Name: "list_dir"},
}},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
}
sanitized, removed := SanitizeHistory(history)
if removed == 0 {
t.Fatal("expected orphaned messages to be removed")
}
if len(sanitized) != 1 || sanitized[0].Role != "user" {
t.Errorf("expected [user], got %d messages", len(sanitized))
}
}
func TestSanitizeHistory_InterleavedMessages(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
}},
{Role: "user", Content: "collision!"},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
{Role: "assistant", Content: "done"},
}
sanitized, removed := SanitizeHistory(history)
if removed == 0 {
t.Fatal("expected interleaved messages to be removed")
}
if len(sanitized) != 3 {
t.Errorf("expected 3 messages, got %d", len(sanitized))
for i, m := range sanitized {
t.Logf(" [%d] role=%s content=%q", i, m.Role, m.Content)
}
}
}
func TestSanitizeHistory_CleanHistory(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
}},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
{Role: "assistant", Content: "done"},
}
sanitized, removed := SanitizeHistory(history)
if removed != 0 {
t.Errorf("expected 0 removed, got %d", removed)
}
if len(sanitized) != 4 {
t.Errorf("expected 4 messages, got %d", len(sanitized))
}
}
func TestSanitizeHistory_MultipleToolCalls(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
{ID: "call_2", Name: "read_file"},
}},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
{Role: "tool", Content: "content", ToolCallID: "call_2"},
{Role: "assistant", Content: "all done"},
}
sanitized, removed := SanitizeHistory(history)
if removed != 0 {
t.Errorf("expected 0 removed, got %d", removed)
}
if len(sanitized) != 5 {
t.Errorf("expected 5 messages, got %d", len(sanitized))
}
}
func TestSanitizeHistory_Empty(t *testing.T) {
sanitized, removed := SanitizeHistory(nil)
if removed != 0 || sanitized != nil {
t.Errorf("expected nil/0, got %v/%d", sanitized, removed)
}
}

View file

@ -4,33 +4,25 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func TestSanitizeFilename(t *testing.T) { func TestSanitizeFilename(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
expected string expected string
}{ }{
{"simple", "simple"}, {"simple", "simple"},
{"telegram:123456", "telegram_123456"}, {"telegram:123456", "telegram_123456"},
{"discord:987654321", "discord_987654321"}, {"discord:987654321", "discord_987654321"},
{"slack:C01234", "slack_C01234"}, {"slack:C01234", "slack_C01234"},
{"no-colons-here", "no-colons-here"}, {"no-colons-here", "no-colons-here"},
{"multiple:colons:here", "multiple_colons_here"}, {"multiple:colons:here", "multiple_colons_here"},
{"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) { t.Run(tt.input, func(t *testing.T) {
got := sanitizeFilename(tt.input) got := sanitizeFilename(tt.input)
if got != tt.expected { if got != tt.expected {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected) t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected)
} }
@ -40,185 +32,54 @@ func TestSanitizeFilename(t *testing.T) {
func TestSave_WithColonInKey(t *testing.T) { func TestSave_WithColonInKey(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
sm := NewSessionManager(tmpDir) sm := NewSessionManager(tmpDir)
// Create a session with a key containing colon (typical channel session key). // Create a session with a key containing colon (typical channel session key).
key := "telegram:123456" key := "telegram:123456"
sm.GetOrCreate(key) sm.GetOrCreate(key)
sm.AddMessage(key, "user", "hello") sm.AddMessage(key, "user", "hello")
// Save should succeed even though the key contains ':' // Save should succeed even though the key contains ':'
if err := sm.Save(key); err != nil { if err := sm.Save(key); err != nil {
t.Fatalf("Save(%q) failed: %v", key, err) t.Fatalf("Save(%q) failed: %v", key, err)
} }
// The file on disk should use sanitized name. // The file on disk should use sanitized name.
expectedFile := filepath.Join(tmpDir, "telegram_123456.json") expectedFile := filepath.Join(tmpDir, "telegram_123456.json")
if _, err := os.Stat(expectedFile); os.IsNotExist(err) { if _, err := os.Stat(expectedFile); os.IsNotExist(err) {
t.Fatalf("expected session file %s to exist", expectedFile) t.Fatalf("expected session file %s to exist", expectedFile)
} }
// Load into a fresh manager and verify the session round-trips. // Load into a fresh manager and verify the session round-trips.
sm2 := NewSessionManager(tmpDir) sm2 := NewSessionManager(tmpDir)
history := sm2.GetHistory(key) history := sm2.GetHistory(key)
if len(history) != 1 { if len(history) != 1 {
t.Fatalf("expected 1 message after reload, got %d", len(history)) t.Fatalf("expected 1 message after reload, got %d", len(history))
} }
if history[0].Content != "hello" { if history[0].Content != "hello" {
t.Errorf("expected message content %q, got %q", "hello", history[0].Content) t.Errorf("expected message content %q, got %q", "hello", history[0].Content)
} }
} }
func TestSanitizeHistory_OrphanedToolCall(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
{ID: "call_2", Name: "list_dir"},
}},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
// Missing tool result for call_2 → orphaned
}
sanitized, removed := SanitizeHistory(history)
if removed == 0 {
t.Fatal("expected orphaned messages to be removed")
}
// After sanitization, only the user message should remain
if len(sanitized) != 1 || sanitized[0].Role != "user" {
t.Errorf("expected [user], got %d messages", len(sanitized))
}
}
func TestSanitizeHistory_InterleavedMessages(t *testing.T) {
// Simulates session collision: a user message got interleaved between
// an assistant tool call and its tool result
history := []providers.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
}},
{Role: "user", Content: "collision!"}, // ← interleaved from other session
{Role: "tool", Content: "ok", ToolCallID: "call_1"}, // ← out of order
{Role: "assistant", Content: "done"},
}
sanitized, removed := SanitizeHistory(history)
if removed == 0 {
t.Fatal("expected interleaved messages to be removed")
}
// Should keep: user("first"), user("collision!"), assistant("done")
// Should remove: assistant(call_1), tool(call_1)
if len(sanitized) != 3 {
t.Errorf("expected 3 messages, got %d", len(sanitized))
for i, m := range sanitized {
t.Logf(" [%d] role=%s content=%q", i, m.Role, m.Content)
}
}
}
func TestSanitizeHistory_CleanHistory(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
}},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
{Role: "assistant", Content: "done"},
}
sanitized, removed := SanitizeHistory(history)
if removed != 0 {
t.Errorf("expected 0 removed, got %d", removed)
}
if len(sanitized) != 4 {
t.Errorf("expected 4 messages, got %d", len(sanitized))
}
}
func TestSanitizeHistory_MultipleToolCalls(t *testing.T) {
history := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec"},
{ID: "call_2", Name: "read_file"},
}},
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
{Role: "tool", Content: "content", ToolCallID: "call_2"},
{Role: "assistant", Content: "all done"},
}
sanitized, removed := SanitizeHistory(history)
if removed != 0 {
t.Errorf("expected 0 removed, got %d", removed)
}
if len(sanitized) != 5 {
t.Errorf("expected 5 messages, got %d", len(sanitized))
}
}
func TestSanitizeHistory_Empty(t *testing.T) {
sanitized, removed := SanitizeHistory(nil)
if removed != 0 || sanitized != nil {
t.Errorf("expected nil/0, got %v/%d", sanitized, removed)
}
}
func TestSave_RejectsPathTraversal(t *testing.T) { func TestSave_RejectsPathTraversal(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
sm := NewSessionManager(tmpDir) sm := NewSessionManager(tmpDir)
badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} // Invalid names that must still be rejected.
badKeys := []string{"", ".", ".."}
for _, key := range badKeys { for _, key := range badKeys {
sm.GetOrCreate(key) sm.GetOrCreate(key)
if err := sm.Save(key); err == nil { if err := sm.Save(key); err == nil {
t.Errorf("Save(%q) should have failed but didn't", key) t.Errorf("Save(%q) should have failed but didn't", key)
} }
} }
// Keys containing path separators are sanitized (no subdirs created).
sm.GetOrCreate("foo/bar")
if err := sm.Save("foo/bar"); err != nil {
t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) {
t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)")
}
} }

View file

@ -342,3 +342,78 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) {
builtin, builtin,
}, roots) }, roots)
} }
func TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "# Plain Skill\n\nThis is parsed from markdown paragraph.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "plain-skill", meta.Name)
assert.Equal(t, "This is parsed from markdown paragraph.", meta.Description)
}
func TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "---\nname: frontmatter-skill\ndescription: frontmatter description\n---\n\n# Plain Skill\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "frontmatter-skill", meta.Name)
assert.Equal(t, "frontmatter description", meta.Description)
}
func TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "---\nname: frontmatter-skill\ndescription: |\n line 1: with colon\n line 2\n---\n\n# Plain Skill\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "frontmatter-skill", meta.Name)
assert.Equal(t, "line 1: with colon\nline 2", meta.Description)
}
func TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "valid-name")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "# Invalid Heading Name\n\nBody description.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "valid-name", meta.Name)
assert.Equal(t, "Body description.", meta.Description)
}
func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) {
tmp := t.TempDir()
skillDir := filepath.Join(tmp, "workspace", "skills", "biomed-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
content := "<!--\n# COPYRIGHT NOTICE\n# This file is part of the \"Universal Biomedical Skills\" project.\n# Copyright (c) 2026 MD BABU MIA, PhD <md.babu.mia@mssm.edu>\n# All Rights Reserved.\n#\n# This code is proprietary and confidential.\n# Unauthorized copying of this file, via any medium is strictly prohibited.\n#\n# Provenance: Authenticated by MD BABU MIA\n\n-->\n\n# Biomed Skill\n\nSummarize biomedical papers.\n"
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
sl := &SkillsLoader{}
meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md"))
require.NotNil(t, meta)
assert.Equal(t, "biomed-skill", meta.Name)
assert.Equal(t, "Summarize biomedical papers.", meta.Description)
}

View file

@ -48,8 +48,8 @@ func NewManager(workspace string) *Manager {
oldStateFile := filepath.Join(workspace, "state.json") oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist // Create state directory if it doesn't exist
if err := os.MkdirAll(stateDir, 0o755); err != nil { if err := os.MkdirAll(stateDir, 0o700); err != nil {
log.Fatalf("[FATAL] state: failed to create state directory: %v", err) log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err)
} }
sm := &Manager{ sm := &Manager{

View file

@ -0,0 +1,38 @@
package state
import (
"os"
"testing"
)
func TestHeartbeatTargetsPersistence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "state-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
sm := NewManager(tmpDir)
if err := sm.SetLastHeartbeatTarget("telegram:-100123"); err != nil {
t.Fatalf("SetLastHeartbeatTarget failed: %v", err)
}
if err := sm.SetHeartbeatTarget("telegram:-100123/42"); err != nil {
t.Fatalf("SetHeartbeatTarget failed: %v", err)
}
if got := sm.GetLastHeartbeatTarget(); got != "telegram:-100123" {
t.Fatalf("GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123")
}
if got := sm.GetHeartbeatTarget(); got != "telegram:-100123/42" {
t.Fatalf("GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42")
}
sm2 := NewManager(tmpDir)
if got := sm2.GetLastHeartbeatTarget(); got != "telegram:-100123" {
t.Fatalf("persistent GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123")
}
if got := sm2.GetHeartbeatTarget(); got != "telegram:-100123/42" {
t.Fatalf("persistent GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42")
}
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@ -215,34 +216,31 @@ func TestNewManager_EmptyWorkspace(t *testing.T) {
} }
} }
func TestHeartbeatTargetsPersistence(t *testing.T) { func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "state-test-*") if os.Getenv("BE_CRASHER") == "1" {
tmpDir := os.Getenv("CRASH_DIR")
statePath := filepath.Join(tmpDir, "state")
if err := os.WriteFile(statePath, []byte("I'm a file, not a folder"), 0o644); err != nil {
fmt.Printf("setup failed: %v", err)
os.Exit(0)
}
NewManager(tmpDir)
os.Exit(0)
}
tmpDir, err := os.MkdirTemp("", "state-crash-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
sm := NewManager(tmpDir) cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureDoesNotCrash")
cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir)
if err := sm.SetLastHeartbeatTarget("telegram:-100123"); err != nil { err = cmd.Run()
t.Fatalf("SetLastHeartbeatTarget failed: %v", err) if err != nil {
} t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err)
if err := sm.SetHeartbeatTarget("telegram:-100123/42"); err != nil {
t.Fatalf("SetHeartbeatTarget failed: %v", err)
}
if got := sm.GetLastHeartbeatTarget(); got != "telegram:-100123" {
t.Fatalf("GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123")
}
if got := sm.GetHeartbeatTarget(); got != "telegram:-100123/42" {
t.Fatalf("GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42")
}
sm2 := NewManager(tmpDir)
if got := sm2.GetLastHeartbeatTarget(); got != "telegram:-100123" {
t.Fatalf("persistent GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123")
}
if got := sm2.GetHeartbeatTarget(); got != "telegram:-100123/42" {
t.Fatalf("persistent GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42")
} }
} }

View file

@ -11,349 +11,261 @@ import (
) )
// TestEditTool_EditFile_Success verifies successful file editing // TestEditTool_EditFile_Success verifies successful file editing
func TestEditTool_EditFile_Success(t *testing.T) { func TestEditTool_EditFile_Success(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644) os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644)
tool := NewEditFileTool(tmpDir, true) tool := NewEditFileTool(tmpDir, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"old_text": "World", "old_text": "World",
"new_text": "Universe", "new_text": "Universe",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Success should not be an error // Success should not be an error
if result.IsError { if result.IsError {
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
} }
// Should return SilentResult // Should return SilentResult
if !result.Silent { if !result.Silent {
t.Errorf("Expected Silent=true for EditFile, got false") t.Errorf("Expected Silent=true for EditFile, got false")
} }
// ForUser should be empty (silent result) // ForUser should be empty (silent result)
if result.ForUser != "" { if result.ForUser != "" {
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
} }
// Verify file was actually edited // Verify file was actually edited
content, err := os.ReadFile(testFile) content, err := os.ReadFile(testFile)
if err != nil { if err != nil {
t.Fatalf("Failed to read edited file: %v", err) t.Fatalf("Failed to read edited file: %v", err)
} }
contentStr := string(content) contentStr := string(content)
if !strings.Contains(contentStr, "Hello Universe") { if !strings.Contains(contentStr, "Hello Universe") {
t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr) t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr)
} }
if strings.Contains(contentStr, "Hello World") { if strings.Contains(contentStr, "Hello World") {
t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr) t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr)
} }
} }
// TestEditTool_EditFile_NotFound verifies error handling for non-existent file // TestEditTool_EditFile_NotFound verifies error handling for non-existent file
func TestEditTool_EditFile_NotFound(t *testing.T) { func TestEditTool_EditFile_NotFound(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "nonexistent.txt") testFile := filepath.Join(tmpDir, "nonexistent.txt")
tool := NewEditFileTool(tmpDir, true) tool := NewEditFileTool(tmpDir, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"old_text": "old", "old_text": "old",
"new_text": "new", "new_text": "new",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error for non-existent file") t.Errorf("Expected error for non-existent file")
} }
// Should mention file not found // Should mention file not found
if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") {
t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM)
} }
} }
// TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist // TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist
func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("Hello World"), 0o644) os.WriteFile(testFile, []byte("Hello World"), 0o644)
tool := NewEditFileTool(tmpDir, true) tool := NewEditFileTool(tmpDir, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"old_text": "Goodbye", "old_text": "Goodbye",
"new_text": "Hello", "new_text": "Hello",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when old_text not found") t.Errorf("Expected error when old_text not found")
} }
// Should mention old_text not found // Should mention old_text not found
if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") {
t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM) t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM)
} }
} }
// TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times // TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times
func TestEditTool_EditFile_MultipleMatches(t *testing.T) { func TestEditTool_EditFile_MultipleMatches(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test test test"), 0o644) os.WriteFile(testFile, []byte("test test test"), 0o644)
tool := NewEditFileTool(tmpDir, true) tool := NewEditFileTool(tmpDir, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"old_text": "test", "old_text": "test",
"new_text": "done", "new_text": "done",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when old_text appears multiple times") t.Errorf("Expected error when old_text appears multiple times")
} }
// Should mention multiple occurrences // Should mention multiple occurrences
if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") { if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") {
t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM) t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM)
} }
} }
// TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory // TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory
func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
otherDir := t.TempDir() otherDir := t.TempDir()
testFile := filepath.Join(otherDir, "test.txt") testFile := filepath.Join(otherDir, "test.txt")
os.WriteFile(testFile, []byte("content"), 0o644) os.WriteFile(testFile, []byte("content"), 0o644)
tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"old_text": "content", "old_text": "content",
"new_text": "new", "new_text": "new",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
assert.True(t, result.IsError, "Expected error when path is outside allowed directory") assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
// Should mention outside allowed directory // Should mention outside allowed directory
// Note: ErrorResult only sets ForLLM by default, so ForUser might be empty. // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
// We check ForLLM as it's the primary error channel. // We check ForLLM as it's the primary error channel.
assert.True( assert.True(
t, t,
strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") || strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") ||
strings.Contains(result.ForLLM, "escapes"), strings.Contains(result.ForLLM, "escapes"),
"Expected 'outside allowed' or 'access denied' message, got ForLLM: %s", "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s",
result.ForLLM, result.ForLLM,
) )
} }
// TestEditTool_EditFile_MissingPath verifies error handling for missing path // TestEditTool_EditFile_MissingPath verifies error handling for missing path
func TestEditTool_EditFile_MissingPath(t *testing.T) { func TestEditTool_EditFile_MissingPath(t *testing.T) {
tool := NewEditFileTool("", false) tool := NewEditFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"old_text": "old", "old_text": "old",
"new_text": "new", "new_text": "new",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when path is missing") t.Errorf("Expected error when path is missing")
} }
} }
// TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text // TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text
func TestEditTool_EditFile_MissingOldText(t *testing.T) { func TestEditTool_EditFile_MissingOldText(t *testing.T) {
tool := NewEditFileTool("", false) tool := NewEditFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/tmp/test.txt", "path": "/tmp/test.txt",
"new_text": "new", "new_text": "new",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when old_text is missing") t.Errorf("Expected error when old_text is missing")
} }
} }
// TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text // TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text
func TestEditTool_EditFile_MissingNewText(t *testing.T) { func TestEditTool_EditFile_MissingNewText(t *testing.T) {
tool := NewEditFileTool("", false) tool := NewEditFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/tmp/test.txt", "path": "/tmp/test.txt",
"old_text": "old", "old_text": "old",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when new_text is missing") t.Errorf("Expected error when new_text is missing")
} }
} }
// TestEditTool_AppendFile_Success verifies successful file appending // TestEditTool_AppendFile_Success verifies successful file appending
func TestEditTool_AppendFile_Success(t *testing.T) { func TestEditTool_AppendFile_Success(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("Initial content"), 0o644) os.WriteFile(testFile, []byte("Initial content"), 0o644)
tool := NewAppendFileTool("", false) tool := NewAppendFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"content": "\nAppended content", "content": "\nAppended content",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Success should not be an error // Success should not be an error
if result.IsError { if result.IsError {
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
} }
// Should return SilentResult // Should return SilentResult
if !result.Silent { if !result.Silent {
t.Errorf("Expected Silent=true for AppendFile, got false") t.Errorf("Expected Silent=true for AppendFile, got false")
} }
// ForUser should be empty (silent result) // ForUser should be empty (silent result)
if result.ForUser != "" { if result.ForUser != "" {
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
} }
// Verify content was actually appended // Verify content was actually appended
content, err := os.ReadFile(testFile) content, err := os.ReadFile(testFile)
if err != nil { if err != nil {
t.Fatalf("Failed to read file: %v", err) t.Fatalf("Failed to read file: %v", err)
} }
contentStr := string(content) contentStr := string(content)
if !strings.Contains(contentStr, "Initial content") { if !strings.Contains(contentStr, "Initial content") {
t.Errorf("Expected original content to remain, got: %s", contentStr) t.Errorf("Expected original content to remain, got: %s", contentStr)
} }
if !strings.Contains(contentStr, "Appended content") { if !strings.Contains(contentStr, "Appended content") {
t.Errorf("Expected appended content, got: %s", contentStr) t.Errorf("Expected appended content, got: %s", contentStr)
} }
} }
// TestEditTool_AppendFile_MissingPath verifies error handling for missing path // TestEditTool_AppendFile_MissingPath verifies error handling for missing path
func TestEditTool_AppendFile_MissingPath(t *testing.T) { func TestEditTool_AppendFile_MissingPath(t *testing.T) {
tool := NewAppendFileTool("", false) tool := NewAppendFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"content": "test", "content": "test",
} }
@ -361,19 +273,15 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when path is missing") t.Errorf("Expected error when path is missing")
} }
} }
// TestEditTool_AppendFile_MissingContent verifies error handling for missing content // TestEditTool_AppendFile_MissingContent verifies error handling for missing content
func TestEditTool_AppendFile_MissingContent(t *testing.T) { func TestEditTool_AppendFile_MissingContent(t *testing.T) {
tool := NewAppendFileTool("", false) tool := NewAppendFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/tmp/test.txt", "path": "/tmp/test.txt",
} }
@ -381,67 +289,43 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when content is missing") t.Errorf("Expected error when content is missing")
} }
} }
// TestReplaceEditContent verifies the helper function replaceEditContent // TestReplaceEditContent verifies the helper function replaceEditContent
func TestReplaceEditContent(t *testing.T) { func TestReplaceEditContent(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content []byte content []byte
oldText string oldText string
newText string newText string
expected []byte expected []byte
expectError bool expectError bool
}{ }{
{ {
name: "successful replacement", name: "successful replacement",
content: []byte("hello world"), content: []byte("hello world"),
oldText: "world", oldText: "world",
newText: "universe", newText: "universe",
expected: []byte("hello universe"), expected: []byte("hello universe"),
expectError: false, expectError: false,
}, },
{ {
name: "old text not found", name: "old text not found",
content: []byte("hello world"), content: []byte("hello world"),
oldText: "golang", oldText: "golang",
newText: "rust", newText: "rust",
expected: nil, expected: nil,
expectError: true, expectError: true,
}, },
{ {
name: "multiple matches found", name: "multiple matches found",
content: []byte("test text test"), content: []byte("test text test"),
oldText: "test", oldText: "test",
newText: "done", newText: "done",
expected: nil, expected: nil,
expectError: true, expectError: true,
}, },
} }
@ -449,12 +333,10 @@ func TestReplaceEditContent(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result, err := replaceEditContent(tt.content, tt.oldText, tt.newText) result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
if tt.expectError { if tt.expectError {
assert.Error(t, err) assert.Error(t, err)
} else { } else {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, tt.expected, result) assert.Equal(t, tt.expected, result)
} }
}) })
@ -462,142 +344,94 @@ func TestReplaceEditContent(t *testing.T) {
} }
// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode // TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
// can append to a file that does not yet exist — it should silently create the file. // can append to a file that does not yet exist — it should silently create the file.
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFile + sandboxFs.
func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) { func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
tool := NewAppendFileTool(workspace, true) tool := NewAppendFileTool(workspace, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "brand_new_file.txt", "path": "brand_new_file.txt",
"content": "first content", "content": "first content",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
assert.False( assert.False(
t, t,
result.IsError, result.IsError,
"Expected success when appending to non-existent file in restricted mode, got: %s", "Expected success when appending to non-existent file in restricted mode, got: %s",
result.ForLLM, result.ForLLM,
) )
// Verify the file was created with correct content // Verify the file was created with correct content
data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt")) data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "first content", string(data)) assert.Equal(t, "first content", string(data))
} }
// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode // TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
// correctly appends to an existing file within the sandbox. // correctly appends to an existing file within the sandbox.
func TestAppendFileTool_Restricted_Success(t *testing.T) { func TestAppendFileTool_Restricted_Success(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
testFile := "existing.txt" testFile := "existing.txt"
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644) err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
assert.NoError(t, err) assert.NoError(t, err)
tool := NewAppendFileTool(workspace, true) tool := NewAppendFileTool(workspace, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"content": " appended", "content": " appended",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
assert.True(t, result.Silent) assert.True(t, result.Silent)
data, err := os.ReadFile(filepath.Join(workspace, testFile)) data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "initial appended", string(data)) assert.Equal(t, "initial appended", string(data))
} }
// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode // TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
// correctly edits a file using the single-open editFileInRoot path.
// correctly edits a file using the sandboxFs path.
func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) { func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
testFile := "edit_target.txt" testFile := "edit_target.txt"
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644) err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
assert.NoError(t, err) assert.NoError(t, err)
tool := NewEditFileTool(workspace, true) tool := NewEditFileTool(workspace, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"old_text": "World", "old_text": "World",
"new_text": "Go", "new_text": "Go",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
assert.True(t, result.Silent) assert.True(t, result.Silent)
data, err := os.ReadFile(filepath.Join(workspace, testFile)) data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "Hello Go", string(data)) assert.Equal(t, "Hello Go", string(data))
} }
// TestEditFileTool_Restricted_FileNotFound verifies that editFile returns a proper // TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
// error message when the target file does not exist. // error message when the target file does not exist.
func TestEditFileTool_Restricted_FileNotFound(t *testing.T) { func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
tool := NewEditFileTool(workspace, true) tool := NewEditFileTool(workspace, true)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "no_such_file.txt", "path": "no_such_file.txt",
"old_text": "old", "old_text": "old",
"new_text": "new", "new_text": "new",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "not found") assert.Contains(t, result.ForLLM, "not found")
} }

View file

@ -0,0 +1,183 @@
package tools
import (
"github.com/stretchr/testify/assert"
"io"
"os"
"path/filepath"
"testing"
)
func TestHostFs_Read_PermissionDenied(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping permission test: running as root")
}
tmpDir := t.TempDir()
protected := filepath.Join(tmpDir, "protected.txt")
err := os.WriteFile(protected, []byte("secret"), 0o000)
assert.NoError(t, err)
defer os.Chmod(protected, 0o644)
_, err = (&hostFs{}).ReadFile(protected)
assert.Error(t, err)
assert.Contains(t, err.Error(), "access denied")
}
func TestHostFs_Read_Directory(t *testing.T) {
tmpDir := t.TempDir()
_, err := (&hostFs{}).ReadFile(tmpDir)
assert.Error(t, err, "expected error when reading a directory as a file")
}
func TestSandboxFs_Read_Directory(t *testing.T) {
workspace := t.TempDir()
root, err := os.OpenRoot(workspace)
assert.NoError(t, err)
defer root.Close()
err = root.Mkdir("subdir", 0o755)
assert.NoError(t, err)
_, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
assert.Error(t, err, "expected error when reading a directory as a file")
}
func TestHostFs_Write_ParentDirMissing(t *testing.T) {
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
err := (&hostFs{}).WriteFile(target, []byte("hello"))
assert.NoError(t, err)
data, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, "hello", string(data))
}
func TestSandboxFs_Write_ParentDirMissing(t *testing.T) {
workspace := t.TempDir()
relPath := "x/y/z/file.txt"
err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
assert.NoError(t, err)
data, err := os.ReadFile(filepath.Join(workspace, relPath))
assert.NoError(t, err)
assert.Equal(t, "nested", string(data))
}
func TestHostFs_Write(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "atomic_test.txt")
testData := []byte("atomic test content")
err := (&hostFs{}).WriteFile(testFile, testData)
assert.NoError(t, err)
content, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, testData, content)
newData := []byte("new atomic content")
err = (&hostFs{}).WriteFile(testFile, newData)
assert.NoError(t, err)
content, err = os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, newData, content)
}
func TestSandboxFs_Write(t *testing.T) {
tmpDir := t.TempDir()
relPath := "atomic_root_test.txt"
testData := []byte("atomic root test content")
erw := &sandboxFs{workspace: tmpDir}
err := erw.WriteFile(relPath, testData)
assert.NoError(t, err)
root, err := os.OpenRoot(tmpDir)
assert.NoError(t, err)
defer root.Close()
f, err := root.Open(relPath)
assert.NoError(t, err)
defer f.Close()
content, err := io.ReadAll(f)
assert.NoError(t, err)
assert.Equal(t, testData, content)
newData := []byte("new root atomic content")
err = erw.WriteFile(relPath, newData)
assert.NoError(t, err)
f2, err := root.Open(relPath)
assert.NoError(t, err)
defer f2.Close()
content, err = io.ReadAll(f2)
assert.NoError(t, err)
assert.Equal(t, newData, content)
}
func TestValidatePath_OutsideWorkspace_IncludesPath(t *testing.T) {
workspace := t.TempDir()
outsidePath := filepath.Join(t.TempDir(), "secret.txt")
_, err := validatePath(outsidePath, workspace, true)
assert.Error(t, err)
assert.Contains(t, err.Error(), "access denied")
assert.Contains(t, err.Error(), workspace)
}

View file

@ -5,6 +5,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
@ -12,18 +13,13 @@ import (
) )
// TestFilesystemTool_ReadFile_Success verifies successful file reading // TestFilesystemTool_ReadFile_Success verifies successful file reading
func TestFilesystemTool_ReadFile_Success(t *testing.T) { func TestFilesystemTool_ReadFile_Success(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644) os.WriteFile(testFile, []byte("test content"), 0o644)
tool := NewReadFileTool("", false) tool := NewReadFileTool("", false, MaxReadFileSize)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
} }
@ -31,33 +27,26 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Success should not be an error // Success should not be an error
if result.IsError { if result.IsError {
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
} }
// ForLLM should contain file content // ForLLM should contain file content
if !strings.Contains(result.ForLLM, "test content") { if !strings.Contains(result.ForLLM, "test content") {
t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM) t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM)
} }
// ReadFile returns NewToolResult which only sets ForLLM, not ForUser // ReadFile returns NewToolResult which only sets ForLLM, not ForUser
// This is the expected behavior - file content goes to LLM, not directly to user // This is the expected behavior - file content goes to LLM, not directly to user
if result.ForUser != "" { if result.ForUser != "" {
t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser) t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser)
} }
} }
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
tool := NewReadFileTool("", false) tool := NewReadFileTool("", false, MaxReadFileSize)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/nonexistent_file_12345.txt", "path": "/nonexistent_file_12345.txt",
} }
@ -65,135 +54,107 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Failure should be marked as error // Failure should be marked as error
if !result.IsError { if !result.IsError {
t.Errorf("Expected error for missing file, got IsError=false") t.Errorf("Expected error for missing file, got IsError=false")
} }
// Should contain error message // Should contain error message
if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
} }
} }
// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path // TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
tool := &ReadFileTool{} tool := &ReadFileTool{}
ctx := context.Background() ctx := context.Background()
args := map[string]any{} args := map[string]any{}
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when path is missing") t.Errorf("Expected error when path is missing")
} }
// Should mention required parameter // Should mention required parameter
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
} }
} }
// TestFilesystemTool_WriteFile_Success verifies successful file writing // TestFilesystemTool_WriteFile_Success verifies successful file writing
func TestFilesystemTool_WriteFile_Success(t *testing.T) { func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt") testFile := filepath.Join(tmpDir, "newfile.txt")
tool := NewWriteFileTool("", false) tool := NewWriteFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"content": "hello world", "content": "hello world",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Success should not be an error // Success should not be an error
if result.IsError { if result.IsError {
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
} }
// WriteFile returns SilentResult // WriteFile returns SilentResult
if !result.Silent { if !result.Silent {
t.Errorf("Expected Silent=true for WriteFile, got false") t.Errorf("Expected Silent=true for WriteFile, got false")
} }
// ForUser should be empty (silent result) // ForUser should be empty (silent result)
if result.ForUser != "" { if result.ForUser != "" {
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
} }
// Verify file was actually written // Verify file was actually written
content, err := os.ReadFile(testFile) content, err := os.ReadFile(testFile)
if err != nil { if err != nil {
t.Fatalf("Failed to read written file: %v", err) t.Fatalf("Failed to read written file: %v", err)
} }
if string(content) != "hello world" { if string(content) != "hello world" {
t.Errorf("Expected file content 'hello world', got: %s", string(content)) t.Errorf("Expected file content 'hello world', got: %s", string(content))
} }
} }
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation // TestFilesystemTool_WriteFile_CreateDir verifies directory creation
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
tool := NewWriteFileTool("", false) tool := NewWriteFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"content": "test", "content": "test",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Success should not be an error // Success should not be an error
if result.IsError { if result.IsError {
t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM) t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM)
} }
// Verify directory was created and file written // Verify directory was created and file written
content, err := os.ReadFile(testFile) content, err := os.ReadFile(testFile)
if err != nil { if err != nil {
t.Fatalf("Failed to read written file: %v", err) t.Fatalf("Failed to read written file: %v", err)
} }
if string(content) != "test" { if string(content) != "test" {
t.Errorf("Expected file content 'test', got: %s", string(content)) t.Errorf("Expected file content 'test', got: %s", string(content))
} }
} }
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path // TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
tool := NewWriteFileTool("", false) tool := NewWriteFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"content": "test", "content": "test",
} }
@ -201,19 +162,15 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when path is missing") t.Errorf("Expected error when path is missing")
} }
} }
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content // TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
tool := NewWriteFileTool("", false) tool := NewWriteFileTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/tmp/test.txt", "path": "/tmp/test.txt",
} }
@ -221,35 +178,26 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error result // Should return error result
if !result.IsError { if !result.IsError {
t.Errorf("Expected error when content is missing") t.Errorf("Expected error when content is missing")
} }
// Should mention required parameter // Should mention required parameter
if !strings.Contains(result.ForLLM, "content is required") && if !strings.Contains(result.ForLLM, "content is required") &&
!strings.Contains(result.ForUser, "content is required") { !strings.Contains(result.ForUser, "content is required") {
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
} }
} }
// TestFilesystemTool_ListDir_Success verifies successful directory listing // TestFilesystemTool_ListDir_Success verifies successful directory listing
func TestFilesystemTool_ListDir_Success(t *testing.T) { func TestFilesystemTool_ListDir_Success(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644) os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
tool := NewListDirTool("", false) tool := NewListDirTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": tmpDir, "path": tmpDir,
} }
@ -257,29 +205,23 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Success should not be an error // Success should not be an error
if result.IsError { if result.IsError {
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
} }
// Should list files and directories // Should list files and directories
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
t.Errorf("Expected files in listing, got: %s", result.ForLLM) t.Errorf("Expected files in listing, got: %s", result.ForLLM)
} }
if !strings.Contains(result.ForLLM, "subdir") { if !strings.Contains(result.ForLLM, "subdir") {
t.Errorf("Expected subdir in listing, got: %s", result.ForLLM) t.Errorf("Expected subdir in listing, got: %s", result.ForLLM)
} }
} }
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory // TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
func TestFilesystemTool_ListDir_NotFound(t *testing.T) { func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
tool := NewListDirTool("", false) tool := NewListDirTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"path": "/nonexistent_directory_12345", "path": "/nonexistent_directory_12345",
} }
@ -287,61 +229,49 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Failure should be marked as error // Failure should be marked as error
if !result.IsError { if !result.IsError {
t.Errorf("Expected error for non-existent directory, got IsError=false") t.Errorf("Expected error for non-existent directory, got IsError=false")
} }
// Should contain error message // Should contain error message
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
} }
} }
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory // TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
tool := NewListDirTool("", false) tool := NewListDirTool("", false)
ctx := context.Background() ctx := context.Background()
args := map[string]any{} args := map[string]any{}
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should use "." as default path // Should use "." as default path
if result.IsError { if result.IsError {
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM) t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
} }
} }
// Block paths that look inside workspace but point outside via symlink. // Block paths that look inside workspace but point outside via symlink.
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir() root := t.TempDir()
workspace := filepath.Join(root, "workspace") workspace := filepath.Join(root, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil { if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err) t.Fatalf("failed to create workspace: %v", err)
} }
secret := filepath.Join(root, "secret.txt") secret := filepath.Join(root, "secret.txt")
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil { if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
t.Fatalf("failed to write secret file: %v", err) t.Fatalf("failed to write secret file: %v", err)
} }
link := filepath.Join(workspace, "leak.txt") link := filepath.Join(workspace, "leak.txt")
if err := os.Symlink(secret, link); err != nil { if err := os.Symlink(secret, link); err != nil {
t.Skipf("symlink not supported in this environment: %v", err) t.Skipf("symlink not supported in this environment: %v", err)
} }
tool := NewReadFileTool(workspace, true) tool := NewReadFileTool(workspace, true, MaxReadFileSize)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"path": link, "path": link,
}) })
@ -349,29 +279,21 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
if !result.IsError { if !result.IsError {
t.Fatalf("expected symlink escape to be blocked") t.Fatalf("expected symlink escape to be blocked")
} }
// os.Root might return different errors depending on platform/implementation // os.Root might return different errors depending on platform/implementation
// but it definitely should error. // but it definitely should error.
// Our wrapper returns "access denied or file not found" // Our wrapper returns "access denied or file not found"
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
!strings.Contains(result.ForLLM, "no such file") { !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
} }
} }
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
tool := NewReadFileTool("", true) // restrict=true but workspace="" tool := NewReadFileTool("", true, MaxReadFileSize) // restrict=true but workspace=""
// Try to read a sensitive file (simulated by a temp file outside workspace) // Try to read a sensitive file (simulated by a temp file outside workspace)
tmpDir := t.TempDir() tmpDir := t.TempDir()
secretFile := filepath.Join(tmpDir, "shadow") secretFile := filepath.Join(tmpDir, "shadow")
os.WriteFile(secretFile, []byte("secret data"), 0o600) os.WriteFile(secretFile, []byte("secret data"), 0o600)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
@ -379,293 +301,346 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
}) })
// We EXPECT IsError=true (access blocked due to empty workspace) // We EXPECT IsError=true (access blocked due to empty workspace)
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
// Verify it failed for the right reason // Verify it failed for the right reason
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
} }
// TestRootMkdirAll verifies that root.MkdirAll (used by sandboxFs.WriteFile) handles all cases: // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. // single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
func TestRootMkdirAll(t *testing.T) { func TestRootMkdirAll(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
root, err := os.OpenRoot(workspace) root, err := os.OpenRoot(workspace)
if err != nil { if err != nil {
t.Fatalf("failed to open root: %v", err) t.Fatalf("failed to open root: %v", err)
} }
defer root.Close() defer root.Close()
// Case 1: Single directory // Case 1: Single directory
err = root.MkdirAll("dir1", 0o755) err = root.MkdirAll("dir1", 0o755)
assert.NoError(t, err) assert.NoError(t, err)
_, err = os.Stat(filepath.Join(workspace, "dir1")) _, err = os.Stat(filepath.Join(workspace, "dir1"))
assert.NoError(t, err) assert.NoError(t, err)
// Case 2: Deeply nested directory // Case 2: Deeply nested directory
err = root.MkdirAll("a/b/c/d", 0o755) err = root.MkdirAll("a/b/c/d", 0o755)
assert.NoError(t, err) assert.NoError(t, err)
_, err = os.Stat(filepath.Join(workspace, "a/b/c/d")) _, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
assert.NoError(t, err) assert.NoError(t, err)
// Case 3: Already exists — must be idempotent // Case 3: Already exists — must be idempotent
err = root.MkdirAll("a/b/c/d", 0o755) err = root.MkdirAll("a/b/c/d", 0o755)
assert.NoError(t, err) assert.NoError(t, err)
// Case 4: A regular file blocks directory creation — must error // Case 4: A regular file blocks directory creation — must error
err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644) err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
assert.NoError(t, err) assert.NoError(t, err)
err = root.MkdirAll("file_exists", 0o755) err = root.MkdirAll("file_exists", 0o755)
assert.Error(t, err, "expected error when a file exists at the directory path") assert.Error(t, err, "expected error when a file exists at the directory path")
} }
func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
tool := NewWriteFileTool(workspace, true) tool := NewWriteFileTool(workspace, true)
ctx := context.Background() ctx := context.Background()
testFile := "deep/nested/path/to/file.txt" testFile := "deep/nested/path/to/file.txt"
content := "deep content" content := "deep content"
args := map[string]any{ args := map[string]any{
"path": testFile, "path": testFile,
"content": content, "content": content,
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
// Verify file content // Verify file content
actualPath := filepath.Join(workspace, testFile) actualPath := filepath.Join(workspace, testFile)
data, err := os.ReadFile(actualPath) data, err := os.ReadFile(actualPath)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, content, string(data)) assert.Equal(t, content, string(data))
} }
// TestHostFs_Read_PermissionDenied verifies that hostFs.ReadFile surfaces access denied errors. // TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
func TestHostRW_Read_PermissionDenied(t *testing.T) {
func TestHostFs_Read_PermissionDenied(t *testing.T) {
if os.Getuid() == 0 { if os.Getuid() == 0 {
t.Skip("skipping permission test: running as root") t.Skip("skipping permission test: running as root")
} }
tmpDir := t.TempDir() tmpDir := t.TempDir()
protected := filepath.Join(tmpDir, "protected.txt") protected := filepath.Join(tmpDir, "protected.txt")
err := os.WriteFile(protected, []byte("secret"), 0o000) err := os.WriteFile(protected, []byte("secret"), 0o000)
assert.NoError(t, err) assert.NoError(t, err)
defer os.Chmod(protected, 0o644) // ensure cleanup defer os.Chmod(protected, 0o644) // ensure cleanup
_, err = (&hostFs{}).ReadFile(protected) _, err = (&hostFs{}).ReadFile(protected)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "access denied") assert.Contains(t, err.Error(), "access denied")
} }
// TestHostFs_Read_Directory verifies that hostFs.ReadFile returns an error when given a directory path. // TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
func TestHostRW_Read_Directory(t *testing.T) {
func TestHostFs_Read_Directory(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_, err := (&hostFs{}).ReadFile(tmpDir) _, err := (&hostFs{}).ReadFile(tmpDir)
assert.Error(t, err, "expected error when reading a directory as a file") assert.Error(t, err, "expected error when reading a directory as a file")
} }
// TestSandboxFs_Read_Directory verifies that sandboxFs.ReadFile returns an error when given a directory. // TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
func TestRootRW_Read_Directory(t *testing.T) {
func TestSandboxFs_Read_Directory(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
root, err := os.OpenRoot(workspace) root, err := os.OpenRoot(workspace)
assert.NoError(t, err) assert.NoError(t, err)
defer root.Close() defer root.Close()
// Create a subdirectory // Create a subdirectory
err = root.Mkdir("subdir", 0o755) err = root.Mkdir("subdir", 0o755)
assert.NoError(t, err) assert.NoError(t, err)
_, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
assert.Error(t, err, "expected error when reading a directory as a file") assert.Error(t, err, "expected error when reading a directory as a file")
} }
// TestHostFs_Write_ParentDirMissing verifies that hostFs.WriteFile creates parent dirs automatically. // TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
func TestHostRW_Write_ParentDirMissing(t *testing.T) {
func TestHostFs_Write_ParentDirMissing(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
err := (&hostFs{}).WriteFile(target, []byte("hello")) err := (&hostFs{}).WriteFile(target, []byte("hello"))
assert.NoError(t, err) assert.NoError(t, err)
data, err := os.ReadFile(target) data, err := os.ReadFile(target)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "hello", string(data)) assert.Equal(t, "hello", string(data))
} }
// TestSandboxFs_Write_ParentDirMissing verifies that sandboxFs.WriteFile creates // TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
// nested parent directories automatically within the sandbox. // nested parent directories automatically within the sandbox.
func TestRootRW_Write_ParentDirMissing(t *testing.T) {
func TestSandboxFs_Write_ParentDirMissing(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
relPath := "x/y/z/file.txt" relPath := "x/y/z/file.txt"
err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
assert.NoError(t, err) assert.NoError(t, err)
data, err := os.ReadFile(filepath.Join(workspace, relPath)) data, err := os.ReadFile(filepath.Join(workspace, relPath))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "nested", string(data)) assert.Equal(t, "nested", string(data))
} }
// TestHostFs_Write verifies the hostFs.WriteFile helper function // TestHostRW_Write verifies the hostRW.Write helper function
func TestHostRW_Write(t *testing.T) {
func TestHostFs_Write(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "atomic_test.txt") testFile := filepath.Join(tmpDir, "atomic_test.txt")
testData := []byte("atomic test content") testData := []byte("atomic test content")
err := (&hostFs{}).WriteFile(testFile, testData) err := (&hostFs{}).WriteFile(testFile, testData)
assert.NoError(t, err) assert.NoError(t, err)
content, err := os.ReadFile(testFile) content, err := os.ReadFile(testFile)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, testData, content) assert.Equal(t, testData, content)
// Verify it overwrites correctly // Verify it overwrites correctly
newData := []byte("new atomic content") newData := []byte("new atomic content")
err = (&hostFs{}).WriteFile(testFile, newData) err = (&hostFs{}).WriteFile(testFile, newData)
assert.NoError(t, err) assert.NoError(t, err)
content, err = os.ReadFile(testFile) content, err = os.ReadFile(testFile)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, newData, content) assert.Equal(t, newData, content)
} }
// TestSandboxFs_Write verifies the sandboxFs.WriteFile helper function // TestRootRW_Write verifies the rootRW.Write helper function
func TestRootRW_Write(t *testing.T) {
func TestSandboxFs_Write(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
relPath := "atomic_root_test.txt" relPath := "atomic_root_test.txt"
testData := []byte("atomic root test content") testData := []byte("atomic root test content")
erw := &sandboxFs{workspace: tmpDir} erw := &sandboxFs{workspace: tmpDir}
err := erw.WriteFile(relPath, testData) err := erw.WriteFile(relPath, testData)
assert.NoError(t, err) assert.NoError(t, err)
root, err := os.OpenRoot(tmpDir) root, err := os.OpenRoot(tmpDir)
assert.NoError(t, err) assert.NoError(t, err)
defer root.Close() defer root.Close()
f, err := root.Open(relPath) f, err := root.Open(relPath)
assert.NoError(t, err) assert.NoError(t, err)
defer f.Close() defer f.Close()
content, err := io.ReadAll(f) content, err := io.ReadAll(f)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, testData, content) assert.Equal(t, testData, content)
// Verify it overwrites correctly // Verify it overwrites correctly
newData := []byte("new root atomic content") newData := []byte("new root atomic content")
err = erw.WriteFile(relPath, newData) err = erw.WriteFile(relPath, newData)
assert.NoError(t, err) assert.NoError(t, err)
f2, err := root.Open(relPath) f2, err := root.Open(relPath)
assert.NoError(t, err) assert.NoError(t, err)
defer f2.Close() defer f2.Close()
content, err = io.ReadAll(f2) content, err = io.ReadAll(f2)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, newData, content) assert.Equal(t, newData, content)
} }
// TestValidatePath_OutsideWorkspace_IncludesPath verifies that the access // TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to
// paths matching the whitelist patterns while blocking non-matching paths.
// denied error includes the workspace path so the caller knows the boundary. func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
func TestValidatePath_OutsideWorkspace_IncludesPath(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "allowed.txt")
os.WriteFile(outsideFile, []byte("outside content"), 0o644)
outsidePath := filepath.Join(t.TempDir(), "secret.txt") // Pattern allows access to the outsideDir.
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))}
_, err := validatePath(outsidePath, workspace, true) tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
assert.Error(t, err) // Read from whitelisted path should succeed.
result := tool.Execute(context.Background(), map[string]any{"path": outsideFile})
assert.Contains(t, err.Error(), "access denied") if result.IsError {
t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM)
assert.Contains(t, err.Error(), workspace) }
if !strings.Contains(result.ForLLM, "outside content") {
t.Errorf("expected file content, got: %s", result.ForLLM)
}
// Read from non-whitelisted path outside workspace should fail.
otherDir := t.TempDir()
otherFile := filepath.Join(otherDir, "blocked.txt")
os.WriteFile(otherFile, []byte("blocked"), 0o644)
result = tool.Execute(context.Background(), map[string]any{"path": otherFile})
if !result.IsError {
t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM)
}
}
// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool
// by reading a file in multiple chunks using 'offset' and 'length'.
func TestReadFileTool_ChunkedReading(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "pagination_test.txt")
// Create a test file with exactly 26 bytes of content
fullContent := "abcdefghijklmnopqrstuvwxyz"
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
// --- Step 1: Read the first chunk (10 bytes) ---
args1 := map[string]any{
"path": testFile,
"offset": 0,
"length": 10,
}
result1 := tool.Execute(ctx, args1)
if result1.IsError {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
// Expect the first 10 characters
if !strings.Contains(result1.ForLLM, "abcdefghij") {
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
}
// Expect the header to indicate the file is truncated
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
}
// Expect the header to suggest the next offset (10)
if !strings.Contains(result1.ForLLM, "offset=10") {
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
}
// Step 2: Read the second chunk (10 bytes) ---
args2 := map[string]any{
"path": testFile,
"offset": 10,
"length": 10,
}
result2 := tool.Execute(ctx, args2)
if result2.IsError {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
// Expect the next 10 characters
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
}
// Expect the header to suggest the next offset (20)
if !strings.Contains(result2.ForLLM, "offset=20") {
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
}
// Step 3: Read the final chunk (remaining 6 bytes) ---
// We ask for 10 bytes, but only 6 are left in the file
args3 := map[string]any{
"path": testFile,
"offset": 20,
"length": 10,
}
result3 := tool.Execute(ctx, args3)
if result3.IsError {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
// Expect the last 6 characters
if !strings.Contains(result3.ForLLM, "uvwxyz") {
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
}
// Expect the header to indicate the end of the file
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
}
// Ensure no TRUNCATED message is present in the final chunk
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
}
}
// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting
// An offset that exceeds the total file size.
func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.txt")
// create a file of only 5 bytes
err := os.WriteFile(testFile, []byte("12345"), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": testFile,
"offset": int64(100), // Offset beyond the end of the file
}
result := tool.Execute(ctx, args)
// It should not be classified as a tool execution error
if result.IsError {
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
}
// Must return EXACTLY the string provided in the code
expectedMsg := "[END OF FILE - no content at this offset]"
if result.ForLLM != expectedMsg {
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
}
} }

View file

@ -9,22 +9,15 @@ import (
func TestMessageTool_Execute_Success(t *testing.T) { func TestMessageTool_Execute_Success(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
var sentChannel, sentChatID, sentContent string var sentChannel, sentChatID, sentContent string
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
sentChannel = channel sentChannel = channel
sentChatID = chatID sentChatID = chatID
sentContent = content sentContent = content
return nil return nil
}) })
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Hello, world!", "content": "Hello, world!",
} }
@ -32,41 +25,33 @@ func TestMessageTool_Execute_Success(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify message was sent with correct parameters // Verify message was sent with correct parameters
if sentChannel != "test-channel" { if sentChannel != "test-channel" {
t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel) t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel)
} }
if sentChatID != "test-chat-id" { if sentChatID != "test-chat-id" {
t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID) t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID)
} }
if sentContent != "Hello, world!" { if sentContent != "Hello, world!" {
t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent) t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent)
} }
// Verify ToolResult meets US-011 criteria: // Verify ToolResult meets US-011 criteria:
// - Send success returns SilentResult (Silent=true) // - Send success returns SilentResult (Silent=true)
if !result.Silent { if !result.Silent {
t.Error("Expected Silent=true for successful send") t.Error("Expected Silent=true for successful send")
} }
// - ForLLM contains send status description // - ForLLM contains send status description
if result.ForLLM != "Message sent to test-channel:test-chat-id" { if result.ForLLM != "Message sent to test-channel:test-chat-id" {
t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM)
} }
// - ForUser is empty (user already received message directly) // - ForUser is empty (user already received message directly)
if result.ForUser != "" { if result.ForUser != "" {
t.Errorf("Expected ForUser to be empty, got '%s'", result.ForUser) t.Errorf("Expected ForUser to be empty, got '%s'", result.ForUser)
} }
// - IsError should be false // - IsError should be false
if result.IsError { if result.IsError {
t.Error("Expected IsError=false for successful send") t.Error("Expected IsError=false for successful send")
} }
@ -75,36 +60,26 @@ func TestMessageTool_Execute_Success(t *testing.T) {
func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("default-channel", "default-chat-id")
var sentChannel, sentChatID string var sentChannel, sentChatID string
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
sentChannel = channel sentChannel = channel
sentChatID = chatID sentChatID = chatID
return nil return nil
}) })
ctx := context.Background() ctx := WithToolContext(context.Background(), "default-channel", "default-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
"channel": "custom-channel", "channel": "custom-channel",
"chat_id": "custom-chat-id", "chat_id": "custom-chat-id",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify custom channel/chatID were used instead of defaults // Verify custom channel/chatID were used instead of defaults
if sentChannel != "custom-channel" { if sentChannel != "custom-channel" {
t.Errorf("Expected channel 'custom-channel', got '%s'", sentChannel) t.Errorf("Expected channel 'custom-channel', got '%s'", sentChannel)
} }
if sentChatID != "custom-chat-id" { if sentChatID != "custom-chat-id" {
t.Errorf("Expected chatID 'custom-chat-id', got '%s'", sentChatID) t.Errorf("Expected chatID 'custom-chat-id', got '%s'", sentChatID)
} }
@ -112,7 +87,6 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
if !result.Silent { if !result.Silent {
t.Error("Expected Silent=true") t.Error("Expected Silent=true")
} }
if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { if result.ForLLM != "Message sent to custom-channel:custom-chat-id" {
t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM)
} }
@ -121,16 +95,12 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
func TestMessageTool_Execute_SendFailure(t *testing.T) { func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
sendErr := errors.New("network error") sendErr := errors.New("network error")
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
return sendErr return sendErr
}) })
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
} }
@ -138,27 +108,21 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify ToolResult for send failure: // Verify ToolResult for send failure:
// - Send failure returns ErrorResult (IsError=true) // - Send failure returns ErrorResult (IsError=true)
if !result.IsError { if !result.IsError {
t.Error("Expected IsError=true for failed send") t.Error("Expected IsError=true for failed send")
} }
// - ForLLM contains error description // - ForLLM contains error description
expectedErrMsg := "sending message: network error" expectedErrMsg := "sending message: network error"
if result.ForLLM != expectedErrMsg { if result.ForLLM != expectedErrMsg {
t.Errorf("Expected ForLLM '%s', got '%s'", expectedErrMsg, result.ForLLM) t.Errorf("Expected ForLLM '%s', got '%s'", expectedErrMsg, result.ForLLM)
} }
// - Err field should contain original error // - Err field should contain original error
if result.Err == nil { if result.Err == nil {
t.Error("Expected Err to be set") t.Error("Expected Err to be set")
} }
if result.Err != sendErr { if result.Err != sendErr {
t.Errorf("Expected Err to be sendErr, got %v", result.Err) t.Errorf("Expected Err to be sendErr, got %v", result.Err)
} }
@ -167,20 +131,15 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
func TestMessageTool_Execute_MissingContent(t *testing.T) { func TestMessageTool_Execute_MissingContent(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id") ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
ctx := context.Background()
args := map[string]any{} // content missing args := map[string]any{} // content missing
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify error result for missing content // Verify error result for missing content
if !result.IsError { if !result.IsError {
t.Error("Expected IsError=true for missing content") t.Error("Expected IsError=true for missing content")
} }
if result.ForLLM != "content is required" { if result.ForLLM != "content is required" {
t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM)
} }
@ -188,15 +147,13 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
// No WithToolContext — channel/chatID are empty
// No SetContext called, so defaultChannel and defaultChatID are empty
tool.SetSendCallback(func(channel, chatID, content string) error { tool.SetSendCallback(func(channel, chatID, content string) error {
return nil return nil
}) })
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
} }
@ -204,11 +161,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify error when no target channel specified // Verify error when no target channel specified
if !result.IsError { if !result.IsError {
t.Error("Expected IsError=true when no target channel") t.Error("Expected IsError=true when no target channel")
} }
if result.ForLLM != "No target channel/chat specified" { if result.ForLLM != "No target channel/chat specified" {
t.Errorf("Expected ForLLM 'No target channel/chat specified', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'No target channel/chat specified', got '%s'", result.ForLLM)
} }
@ -216,13 +171,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
func TestMessageTool_Execute_NotConfigured(t *testing.T) { func TestMessageTool_Execute_NotConfigured(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
tool.SetContext("test-channel", "test-chat-id")
// No SetSendCallback called // No SetSendCallback called
ctx := context.Background() ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{ args := map[string]any{
"content": "Test message", "content": "Test message",
} }
@ -230,11 +181,9 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify error when send callback not configured // Verify error when send callback not configured
if !result.IsError { if !result.IsError {
t.Error("Expected IsError=true when send callback not configured") t.Error("Expected IsError=true when send callback not configured")
} }
if result.ForLLM != "Message sending not configured" { if result.ForLLM != "Message sending not configured" {
t.Errorf("Expected ForLLM 'Message sending not configured', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'Message sending not configured', got '%s'", result.ForLLM)
} }
@ -242,7 +191,6 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) {
func TestMessageTool_Name(t *testing.T) { func TestMessageTool_Name(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
if tool.Name() != "message" { if tool.Name() != "message" {
t.Errorf("Expected name 'message', got '%s'", tool.Name()) t.Errorf("Expected name 'message', got '%s'", tool.Name())
} }
@ -250,9 +198,7 @@ func TestMessageTool_Name(t *testing.T) {
func TestMessageTool_Description(t *testing.T) { func TestMessageTool_Description(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
desc := tool.Description() desc := tool.Description()
if desc == "" { if desc == "" {
t.Error("Description should not be empty") t.Error("Description should not be empty")
} }
@ -260,63 +206,48 @@ func TestMessageTool_Description(t *testing.T) {
func TestMessageTool_Parameters(t *testing.T) { func TestMessageTool_Parameters(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()
params := tool.Parameters() params := tool.Parameters()
// Verify parameters structure // Verify parameters structure
typ, ok := params["type"].(string) typ, ok := params["type"].(string)
if !ok || typ != "object" { if !ok || typ != "object" {
t.Error("Expected type 'object'") t.Error("Expected type 'object'")
} }
props, ok := params["properties"].(map[string]any) props, ok := params["properties"].(map[string]any)
if !ok { if !ok {
t.Fatal("Expected properties to be a map") t.Fatal("Expected properties to be a map")
} }
// Check required properties // Check required properties
required, ok := params["required"].([]string) required, ok := params["required"].([]string)
if !ok || len(required) != 1 || required[0] != "content" { if !ok || len(required) != 1 || required[0] != "content" {
t.Error("Expected 'content' to be required") t.Error("Expected 'content' to be required")
} }
// Check content property // Check content property
contentProp, ok := props["content"].(map[string]any) contentProp, ok := props["content"].(map[string]any)
if !ok { if !ok {
t.Error("Expected 'content' property") t.Error("Expected 'content' property")
} }
if contentProp["type"] != "string" { if contentProp["type"] != "string" {
t.Error("Expected content type to be 'string'") t.Error("Expected content type to be 'string'")
} }
// Check channel property (optional) // Check channel property (optional)
channelProp, ok := props["channel"].(map[string]any) channelProp, ok := props["channel"].(map[string]any)
if !ok { if !ok {
t.Error("Expected 'channel' property") t.Error("Expected 'channel' property")
} }
if channelProp["type"] != "string" { if channelProp["type"] != "string" {
t.Error("Expected channel type to be 'string'") t.Error("Expected channel type to be 'string'")
} }
// Check chat_id property (optional) // Check chat_id property (optional)
chatIDProp, ok := props["chat_id"].(map[string]any) chatIDProp, ok := props["chat_id"].(map[string]any)
if !ok { if !ok {
t.Error("Expected 'chat_id' property") t.Error("Expected 'chat_id' property")
} }
if chatIDProp["type"] != "string" { if chatIDProp["type"] != "string" {
t.Error("Expected chat_id type to be 'string'") t.Error("Expected chat_id type to be 'string'")
} }

View file

@ -0,0 +1,269 @@
package tools
import (
"context"
"strings"
"testing"
)
type mockCtxTool struct {
mockRegistryTool
channel string
chatID string
}
func (m *mockCtxTool) SetContext(channel, chatID string) {
m.channel = channel
m.chatID = chatID
}
func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
m.cb = cb
}
func TestNormalizeToolName(t *testing.T) {
tests := []struct {
input, want string
}{
{"read_file", "readfile"},
{"readfile", "readfile"},
{"ReadFile", "readfile"},
{"read-file", "readfile"},
{"edit_file", "editfile"},
{"web_search", "websearch"},
{"EXEC", "exec"},
}
for _, tt := range tests {
got := NormalizeToolName(tt.input)
if got != tt.want {
t.Errorf("NormalizeToolName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestToolRegistry_Get_FuzzyMatch(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads a file"))
r.Register(newMockTool("edit_file", "edits a file"))
r.Register(newMockTool("web_search", "searches the web"))
tests := []struct {
query string
wantName string
}{
{"readfile", "read_file"},
{"ReadFile", "read_file"},
{"read-file", "read_file"},
{"editfile", "edit_file"},
{"EditFile", "edit_file"},
{"websearch", "web_search"},
{"WebSearch", "web_search"},
}
for _, tt := range tests {
tool, ok := r.Get(tt.query)
if !ok {
t.Errorf("Get(%q) not found, want %q", tt.query, tt.wantName)
continue
}
if tool.Name() != tt.wantName {
t.Errorf("Get(%q).Name() = %q, want %q", tt.query, tool.Name(), tt.wantName)
}
}
}
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
r := NewToolRegistry()
ct := &mockCtxTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
}
r.Register(ct)
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
if ct.channel != "telegram" {
t.Errorf("expected channel 'telegram', got %q", ct.channel)
}
if ct.chatID != "chat-42" {
t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
}
}
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
r := NewToolRegistry()
ct := &mockCtxTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
}
r.Register(ct)
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
if ct.channel != "" || ct.chatID != "" {
t.Error("SetContext should not be called with empty channel/chatID")
}
}
func TestBuildParamHint(t *testing.T) {
tests := []struct {
name string
schema map[string]any
want string
}{
{
name: "required and optional",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{"type": "string"},
"label": map[string]any{"type": "string"},
},
"required": []string{"task"},
},
want: "(task, label?)",
},
{
name: "all required",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string"},
},
"required": []string{"command"},
},
want: "(command)",
},
{
name: "no properties",
schema: map[string]any{
"type": "object",
},
want: "",
},
{
name: "empty schema",
schema: map[string]any{},
want: "",
},
{
name: "nil schema",
schema: nil,
want: "",
},
{
name: "multiple optional sorted",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{"type": "string"},
"preset": map[string]any{"type": "string"},
"label": map[string]any{"type": "string"},
"agent_id": map[string]any{"type": "string"},
},
"required": []string{"task"},
},
want: "(task, agent_id?, label?, preset?)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildParamHint(tt.schema)
if got != tt.want {
t.Errorf("buildParamHint() = %q, want %q", got, tt.want)
}
})
}
}
func TestToolRegistry_GetSummaries_WithParamHint(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockRegistryTool{
name: "spawn",
desc: "Spawn a subagent",
params: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{"type": "string"},
"preset": map[string]any{"type": "string"},
},
"required": []string{"task"},
},
result: SilentResult("ok"),
})
summaries := r.GetSummaries()
if len(summaries) != 1 {
t.Fatalf("expected 1 summary, got %d", len(summaries))
}
if !strings.Contains(summaries[0], "(task, preset?)") {
t.Errorf("expected param hint in summary, got %q", summaries[0])
}
}

View file

@ -13,46 +13,36 @@ import (
type mockRegistryTool struct { type mockRegistryTool struct {
name string name string
desc string desc string
params map[string]any params map[string]any
result *ToolResult result *ToolResult
} }
func (m *mockRegistryTool) Name() string { return m.name } func (m *mockRegistryTool) Name() string { return m.name }
func (m *mockRegistryTool) Description() string { return m.desc } func (m *mockRegistryTool) Description() string { return m.desc }
func (m *mockRegistryTool) Parameters() map[string]any { return m.params } func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult { func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return m.result return m.result
} }
type mockCtxTool struct { type mockContextAwareTool struct {
mockRegistryTool mockRegistryTool
lastCtx context.Context
channel string
chatID string
} }
func (m *mockCtxTool) SetContext(channel, chatID string) { func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult {
m.channel = channel m.lastCtx = ctx
return m.result
m.chatID = chatID
} }
type mockAsyncRegistryTool struct { type mockAsyncRegistryTool struct {
mockRegistryTool mockRegistryTool
lastCB AsyncCallback
cb AsyncCallback
} }
func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) { func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
m.cb = cb m.lastCB = cb
return m.result
} }
// --- helpers --- // --- helpers ---
@ -60,52 +50,19 @@ func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
func newMockTool(name, desc string) *mockRegistryTool { func newMockTool(name, desc string) *mockRegistryTool {
return &mockRegistryTool{ return &mockRegistryTool{
name: name, name: name,
desc: desc, desc: desc,
params: map[string]any{"type": "object"}, params: map[string]any{"type": "object"},
result: SilentResult("ok"), result: SilentResult("ok"),
} }
} }
// --- tests --- // --- tests ---
func TestNormalizeToolName(t *testing.T) {
tests := []struct {
input, want string
}{
{"read_file", "readfile"},
{"readfile", "readfile"},
{"ReadFile", "readfile"},
{"read-file", "readfile"},
{"edit_file", "editfile"},
{"web_search", "websearch"},
{"EXEC", "exec"},
}
for _, tt := range tests {
got := NormalizeToolName(tt.input)
if got != tt.want {
t.Errorf("NormalizeToolName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestNewToolRegistry(t *testing.T) { func TestNewToolRegistry(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
if r.Count() != 0 { if r.Count() != 0 {
t.Errorf("expected empty registry, got count %d", r.Count()) t.Errorf("expected empty registry, got count %d", r.Count())
} }
if len(r.List()) != 0 { if len(r.List()) != 0 {
t.Errorf("expected empty list, got %v", r.List()) t.Errorf("expected empty list, got %v", r.List())
} }
@ -113,17 +70,13 @@ func TestNewToolRegistry(t *testing.T) {
func TestToolRegistry_RegisterAndGet(t *testing.T) { func TestToolRegistry_RegisterAndGet(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
tool := newMockTool("echo", "echoes input") tool := newMockTool("echo", "echoes input")
r.Register(tool) r.Register(tool)
got, ok := r.Get("echo") got, ok := r.Get("echo")
if !ok { if !ok {
t.Fatal("expected to find registered tool") t.Fatal("expected to find registered tool")
} }
if got.Name() != "echo" { if got.Name() != "echo" {
t.Errorf("expected name 'echo', got %q", got.Name()) t.Errorf("expected name 'echo', got %q", got.Name())
} }
@ -131,71 +84,21 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) {
func TestToolRegistry_Get_NotFound(t *testing.T) { func TestToolRegistry_Get_NotFound(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
_, ok := r.Get("nonexistent") _, ok := r.Get("nonexistent")
if ok { if ok {
t.Error("expected ok=false for unregistered tool") t.Error("expected ok=false for unregistered tool")
} }
} }
func TestToolRegistry_Get_FuzzyMatch(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads a file"))
r.Register(newMockTool("edit_file", "edits a file"))
r.Register(newMockTool("web_search", "searches the web"))
tests := []struct {
query string
wantName string
}{
{"readfile", "read_file"},
{"ReadFile", "read_file"},
{"read-file", "read_file"},
{"editfile", "edit_file"},
{"EditFile", "edit_file"},
{"websearch", "web_search"},
{"WebSearch", "web_search"},
}
for _, tt := range tests {
tool, ok := r.Get(tt.query)
if !ok {
t.Errorf("Get(%q) not found, want %q", tt.query, tt.wantName)
continue
}
if tool.Name() != tt.wantName {
t.Errorf("Get(%q).Name() = %q, want %q", tt.query, tool.Name(), tt.wantName)
}
}
}
func TestToolRegistry_RegisterOverwrite(t *testing.T) { func TestToolRegistry_RegisterOverwrite(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(newMockTool("dup", "first")) r.Register(newMockTool("dup", "first"))
r.Register(newMockTool("dup", "second")) r.Register(newMockTool("dup", "second"))
if r.Count() != 1 { if r.Count() != 1 {
t.Errorf("expected count 1 after overwrite, got %d", r.Count()) t.Errorf("expected count 1 after overwrite, got %d", r.Count())
} }
tool, _ := r.Get("dup") tool, _ := r.Get("dup")
if tool.Description() != "second" { if tool.Description() != "second" {
t.Errorf("expected overwritten description 'second', got %q", tool.Description()) t.Errorf("expected overwritten description 'second', got %q", tool.Description())
} }
@ -203,23 +106,17 @@ func TestToolRegistry_RegisterOverwrite(t *testing.T) {
func TestToolRegistry_Execute_Success(t *testing.T) { func TestToolRegistry_Execute_Success(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(&mockRegistryTool{ r.Register(&mockRegistryTool{
name: "greet", name: "greet",
desc: "says hello", desc: "says hello",
params: map[string]any{}, params: map[string]any{},
result: SilentResult("hello"), result: SilentResult("hello"),
}) })
result := r.Execute(context.Background(), "greet", nil) result := r.Execute(context.Background(), "greet", nil)
if result.IsError { if result.IsError {
t.Errorf("expected success, got error: %s", result.ForLLM) t.Errorf("expected success, got error: %s", result.ForLLM)
} }
if result.ForLLM != "hello" { if result.ForLLM != "hello" {
t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM) t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
} }
@ -227,85 +124,79 @@ func TestToolRegistry_Execute_Success(t *testing.T) {
func TestToolRegistry_Execute_NotFound(t *testing.T) { func TestToolRegistry_Execute_NotFound(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
result := r.Execute(context.Background(), "missing", nil) result := r.Execute(context.Background(), "missing", nil)
if !result.IsError { if !result.IsError {
t.Error("expected error for missing tool") t.Error("expected error for missing tool")
} }
if !strings.Contains(result.ForLLM, "not found") { if !strings.Contains(result.ForLLM, "not found") {
t.Errorf("expected 'not found' in error, got %q", result.ForLLM) t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
} }
if result.Err == nil { if result.Err == nil {
t.Error("expected Err to be set via WithError") t.Error("expected Err to be set via WithError")
} }
} }
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) { func TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
ct := &mockContextAwareTool{
ct := &mockCtxTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"), mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
} }
r.Register(ct) r.Register(ct)
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
if ct.channel != "telegram" { if ct.lastCtx == nil {
t.Errorf("expected channel 'telegram', got %q", ct.channel) t.Fatal("expected Execute to be called")
} }
if got := ToolChannel(ct.lastCtx); got != "telegram" {
if ct.chatID != "chat-42" { t.Errorf("expected channel 'telegram', got %q", got)
t.Errorf("expected chatID 'chat-42', got %q", ct.chatID) }
if got := ToolChatID(ct.lastCtx); got != "chat-42" {
t.Errorf("expected chatID 'chat-42', got %q", got)
} }
} }
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) { func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
ct := &mockContextAwareTool{
ct := &mockCtxTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"), mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
} }
r.Register(ct) r.Register(ct)
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
if ct.channel != "" || ct.chatID != "" { if ct.lastCtx == nil {
t.Error("SetContext should not be called with empty channel/chatID") t.Fatal("expected Execute to be called")
}
// Empty values are still injected; tools decide what to do with them.
if got := ToolChannel(ct.lastCtx); got != "" {
t.Errorf("expected empty channel, got %q", got)
}
if got := ToolChatID(ct.lastCtx); got != "" {
t.Errorf("expected empty chatID, got %q", got)
} }
} }
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
at := &mockAsyncRegistryTool{ at := &mockAsyncRegistryTool{
mockRegistryTool: *newMockTool("async_tool", "async work"), mockRegistryTool: *newMockTool("async_tool", "async work"),
} }
at.result = AsyncResult("started") at.result = AsyncResult("started")
r.Register(at) r.Register(at)
called := false called := false
cb := func(_ context.Context, _ *ToolResult) { called = true } cb := func(_ context.Context, _ *ToolResult) { called = true }
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb) result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
if at.lastCB == nil {
if at.cb == nil { t.Error("expected ExecuteAsync to have received a callback")
t.Error("expected SetCallback to have been called")
} }
if !result.Async { if !result.Async {
t.Error("expected async result") t.Error("expected async result")
} }
at.cb(context.Background(), SilentResult("done")) at.lastCB(context.Background(), SilentResult("done"))
if !called { if !called {
t.Error("expected callback to be invoked") t.Error("expected callback to be invoked")
} }
@ -313,29 +204,22 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
func TestToolRegistry_GetDefinitions(t *testing.T) { func TestToolRegistry_GetDefinitions(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(newMockTool("alpha", "tool A")) r.Register(newMockTool("alpha", "tool A"))
defs := r.GetDefinitions() defs := r.GetDefinitions()
if len(defs) != 1 { if len(defs) != 1 {
t.Fatalf("expected 1 definition, got %d", len(defs)) t.Fatalf("expected 1 definition, got %d", len(defs))
} }
if defs[0]["type"] != "function" { if defs[0]["type"] != "function" {
t.Errorf("expected type 'function', got %v", defs[0]["type"]) t.Errorf("expected type 'function', got %v", defs[0]["type"])
} }
fn, ok := defs[0]["function"].(map[string]any) fn, ok := defs[0]["function"].(map[string]any)
if !ok { if !ok {
t.Fatal("expected 'function' key to be a map") t.Fatal("expected 'function' key to be a map")
} }
if fn["name"] != "alpha" { if fn["name"] != "alpha" {
t.Errorf("expected name 'alpha', got %v", fn["name"]) t.Errorf("expected name 'alpha', got %v", fn["name"])
} }
if fn["description"] != "tool A" { if fn["description"] != "tool A" {
t.Errorf("expected description 'tool A', got %v", fn["description"]) t.Errorf("expected description 'tool A', got %v", fn["description"])
} }
@ -343,47 +227,34 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
func TestToolRegistry_ToProviderDefs(t *testing.T) { func TestToolRegistry_ToProviderDefs(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
params := map[string]any{"type": "object", "properties": map[string]any{}} params := map[string]any{"type": "object", "properties": map[string]any{}}
r.Register(&mockRegistryTool{ r.Register(&mockRegistryTool{
name: "beta", name: "beta",
desc: "tool B", desc: "tool B",
params: params, params: params,
result: SilentResult("ok"), result: SilentResult("ok"),
}) })
defs := r.ToProviderDefs() defs := r.ToProviderDefs()
if len(defs) != 1 { if len(defs) != 1 {
t.Fatalf("expected 1 provider def, got %d", len(defs)) t.Fatalf("expected 1 provider def, got %d", len(defs))
} }
want := providers.ToolDefinition{ want := providers.ToolDefinition{
Type: "function", Type: "function",
Function: providers.ToolFunctionDefinition{ Function: providers.ToolFunctionDefinition{
Name: "beta", Name: "beta",
Description: "tool B", Description: "tool B",
Parameters: params,
Parameters: providers.MustMarshalParameters(params),
}, },
} }
got := defs[0] got := defs[0]
if got.Type != want.Type { if got.Type != want.Type {
t.Errorf("Type: want %q, got %q", want.Type, got.Type) t.Errorf("Type: want %q, got %q", want.Type, got.Type)
} }
if got.Function.Name != want.Function.Name { if got.Function.Name != want.Function.Name {
t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name) t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
} }
if got.Function.Description != want.Function.Description { if got.Function.Description != want.Function.Description {
t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description) t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
} }
@ -391,23 +262,18 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) {
func TestToolRegistry_List(t *testing.T) { func TestToolRegistry_List(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(newMockTool("x", "")) r.Register(newMockTool("x", ""))
r.Register(newMockTool("y", "")) r.Register(newMockTool("y", ""))
names := r.List() names := r.List()
if len(names) != 2 { if len(names) != 2 {
t.Fatalf("expected 2 names, got %d", len(names)) t.Fatalf("expected 2 names, got %d", len(names))
} }
nameSet := map[string]bool{} nameSet := map[string]bool{}
for _, n := range names { for _, n := range names {
nameSet[n] = true nameSet[n] = true
} }
if !nameSet["x"] || !nameSet["y"] { if !nameSet["x"] || !nameSet["y"] {
t.Errorf("expected names {x, y}, got %v", names) t.Errorf("expected names {x, y}, got %v", names)
} }
@ -415,207 +281,55 @@ func TestToolRegistry_List(t *testing.T) {
func TestToolRegistry_Count(t *testing.T) { func TestToolRegistry_Count(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
if r.Count() != 0 { if r.Count() != 0 {
t.Errorf("expected 0, got %d", r.Count()) t.Errorf("expected 0, got %d", r.Count())
} }
r.Register(newMockTool("a", "")) r.Register(newMockTool("a", ""))
r.Register(newMockTool("b", "")) r.Register(newMockTool("b", ""))
if r.Count() != 2 { if r.Count() != 2 {
t.Errorf("expected 2, got %d", r.Count()) t.Errorf("expected 2, got %d", r.Count())
} }
r.Register(newMockTool("a", "replaced")) r.Register(newMockTool("a", "replaced"))
if r.Count() != 2 { if r.Count() != 2 {
t.Errorf("expected 2 after overwrite, got %d", r.Count()) t.Errorf("expected 2 after overwrite, got %d", r.Count())
} }
} }
func TestBuildParamHint(t *testing.T) {
tests := []struct {
name string
schema map[string]any
want string
}{
{
name: "required and optional",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{"type": "string"},
"label": map[string]any{"type": "string"},
},
"required": []string{"task"},
},
want: "(task, label?)",
},
{
name: "all required",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string"},
},
"required": []string{"command"},
},
want: "(command)",
},
{
name: "no properties",
schema: map[string]any{
"type": "object",
},
want: "",
},
{
name: "empty schema",
schema: map[string]any{},
want: "",
},
{
name: "nil schema",
schema: nil,
want: "",
},
{
name: "multiple optional sorted",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{"type": "string"},
"preset": map[string]any{"type": "string"},
"label": map[string]any{"type": "string"},
"agent_id": map[string]any{"type": "string"},
},
"required": []string{"task"},
},
want: "(task, agent_id?, label?, preset?)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildParamHint(tt.schema)
if got != tt.want {
t.Errorf("buildParamHint() = %q, want %q", got, tt.want)
}
})
}
}
func TestToolRegistry_GetSummaries(t *testing.T) { func TestToolRegistry_GetSummaries(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(newMockTool("read_file", "Reads a file")) r.Register(newMockTool("read_file", "Reads a file"))
summaries := r.GetSummaries() summaries := r.GetSummaries()
if len(summaries) != 1 { if len(summaries) != 1 {
t.Fatalf("expected 1 summary, got %d", len(summaries)) t.Fatalf("expected 1 summary, got %d", len(summaries))
} }
if !strings.Contains(summaries[0], "`read_file`") { if !strings.Contains(summaries[0], "`read_file`") {
t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0]) t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
} }
if !strings.Contains(summaries[0], "Reads a file") { if !strings.Contains(summaries[0], "Reads a file") {
t.Errorf("expected description in summary, got %q", summaries[0]) t.Errorf("expected description in summary, got %q", summaries[0])
} }
} }
func TestToolRegistry_GetSummaries_WithParamHint(t *testing.T) {
r := NewToolRegistry()
r.Register(&mockRegistryTool{
name: "spawn",
desc: "Spawn a subagent",
params: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{"type": "string"},
"preset": map[string]any{"type": "string"},
},
"required": []string{"task"},
},
result: SilentResult("ok"),
})
summaries := r.GetSummaries()
if len(summaries) != 1 {
t.Fatalf("expected 1 summary, got %d", len(summaries))
}
// Should contain param hint
if !strings.Contains(summaries[0], "(task, preset?)") {
t.Errorf("expected param hint in summary, got %q", summaries[0])
}
}
func TestToolToSchema(t *testing.T) { func TestToolToSchema(t *testing.T) {
tool := newMockTool("demo", "demo tool") tool := newMockTool("demo", "demo tool")
schema := ToolToSchema(tool) schema := ToolToSchema(tool)
if schema["type"] != "function" { if schema["type"] != "function" {
t.Errorf("expected type 'function', got %v", schema["type"]) t.Errorf("expected type 'function', got %v", schema["type"])
} }
fn, ok := schema["function"].(map[string]any) fn, ok := schema["function"].(map[string]any)
if !ok { if !ok {
t.Fatal("expected 'function' to be a map") t.Fatal("expected 'function' to be a map")
} }
if fn["name"] != "demo" { if fn["name"] != "demo" {
t.Errorf("expected name 'demo', got %v", fn["name"]) t.Errorf("expected name 'demo', got %v", fn["name"])
} }
if fn["description"] != "demo tool" { if fn["description"] != "demo tool" {
t.Errorf("expected description 'demo tool', got %v", fn["description"]) t.Errorf("expected description 'demo tool', got %v", fn["description"])
} }
if fn["parameters"] == nil { if fn["parameters"] == nil {
t.Error("expected parameters to be set") t.Error("expected parameters to be set")
} }
@ -623,25 +337,17 @@ func TestToolToSchema(t *testing.T) {
func TestToolRegistry_ConcurrentAccess(t *testing.T) { func TestToolRegistry_ConcurrentAccess(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
var wg sync.WaitGroup var wg sync.WaitGroup
for i := range 50 { for i := range 50 {
wg.Add(1) wg.Add(1)
go func(n int) { go func(n int) {
defer wg.Done() defer wg.Done()
name := string(rune('A' + n%26)) name := string(rune('A' + n%26))
r.Register(newMockTool(name, "concurrent")) r.Register(newMockTool(name, "concurrent"))
r.Get(name) r.Get(name)
r.Count() r.Count()
r.List() r.List()
r.GetDefinitions() r.GetDefinitions()
}(i) }(i)
} }

View file

@ -12,15 +12,12 @@ func TestNewToolResult(t *testing.T) {
if result.ForLLM != "test content" { if result.ForLLM != "test content" {
t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM)
} }
if result.Silent { if result.Silent {
t.Error("Expected Silent to be false") t.Error("Expected Silent to be false")
} }
if result.IsError { if result.IsError {
t.Error("Expected IsError to be false") t.Error("Expected IsError to be false")
} }
if result.Async { if result.Async {
t.Error("Expected Async to be false") t.Error("Expected Async to be false")
} }
@ -32,15 +29,12 @@ func TestSilentResult(t *testing.T) {
if result.ForLLM != "silent operation" { if result.ForLLM != "silent operation" {
t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM)
} }
if !result.Silent { if !result.Silent {
t.Error("Expected Silent to be true") t.Error("Expected Silent to be true")
} }
if result.IsError { if result.IsError {
t.Error("Expected IsError to be false") t.Error("Expected IsError to be false")
} }
if result.Async { if result.Async {
t.Error("Expected Async to be false") t.Error("Expected Async to be false")
} }
@ -52,15 +46,12 @@ func TestAsyncResult(t *testing.T) {
if result.ForLLM != "async task started" { if result.ForLLM != "async task started" {
t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM)
} }
if result.Silent { if result.Silent {
t.Error("Expected Silent to be false") t.Error("Expected Silent to be false")
} }
if result.IsError { if result.IsError {
t.Error("Expected IsError to be false") t.Error("Expected IsError to be false")
} }
if !result.Async { if !result.Async {
t.Error("Expected Async to be true") t.Error("Expected Async to be true")
} }
@ -72,15 +63,12 @@ func TestErrorResult(t *testing.T) {
if result.ForLLM != "operation failed" { if result.ForLLM != "operation failed" {
t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM) t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM)
} }
if result.Silent { if result.Silent {
t.Error("Expected Silent to be false") t.Error("Expected Silent to be false")
} }
if !result.IsError { if !result.IsError {
t.Error("Expected IsError to be true") t.Error("Expected IsError to be true")
} }
if result.Async { if result.Async {
t.Error("Expected Async to be false") t.Error("Expected Async to be false")
} }
@ -88,25 +76,20 @@ func TestErrorResult(t *testing.T) {
func TestUserResult(t *testing.T) { func TestUserResult(t *testing.T) {
content := "user visible message" content := "user visible message"
result := UserResult(content) result := UserResult(content)
if result.ForLLM != content { if result.ForLLM != content {
t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM) t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM)
} }
if result.ForUser != content { if result.ForUser != content {
t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser) t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser)
} }
if result.Silent { if result.Silent {
t.Error("Expected Silent to be false") t.Error("Expected Silent to be false")
} }
if result.IsError { if result.IsError {
t.Error("Expected IsError to be false") t.Error("Expected IsError to be false")
} }
if result.Async { if result.Async {
t.Error("Expected Async to be false") t.Error("Expected Async to be false")
} }
@ -115,36 +98,26 @@ func TestUserResult(t *testing.T) {
func TestToolResultJSONSerialization(t *testing.T) { func TestToolResultJSONSerialization(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
result *ToolResult result *ToolResult
}{ }{
{ {
name: "basic result", name: "basic result",
result: NewToolResult("basic content"), result: NewToolResult("basic content"),
}, },
{ {
name: "silent result", name: "silent result",
result: SilentResult("silent content"), result: SilentResult("silent content"),
}, },
{ {
name: "async result", name: "async result",
result: AsyncResult("async content"), result: AsyncResult("async content"),
}, },
{ {
name: "error result", name: "error result",
result: ErrorResult("error content"), result: ErrorResult("error content"),
}, },
{ {
name: "user result", name: "user result",
result: UserResult("user content"), result: UserResult("user content"),
}, },
} }
@ -152,38 +125,30 @@ func TestToolResultJSONSerialization(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Marshal to JSON // Marshal to JSON
data, err := json.Marshal(tt.result) data, err := json.Marshal(tt.result)
if err != nil { if err != nil {
t.Fatalf("Failed to marshal: %v", err) t.Fatalf("Failed to marshal: %v", err)
} }
// Unmarshal back // Unmarshal back
var decoded ToolResult var decoded ToolResult
if err := json.Unmarshal(data, &decoded); err != nil { if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Failed to unmarshal: %v", err) t.Fatalf("Failed to unmarshal: %v", err)
} }
// Verify fields match (Err should be excluded) // Verify fields match (Err should be excluded)
if decoded.ForLLM != tt.result.ForLLM { if decoded.ForLLM != tt.result.ForLLM {
t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM)
} }
if decoded.ForUser != tt.result.ForUser { if decoded.ForUser != tt.result.ForUser {
t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser) t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser)
} }
if decoded.Silent != tt.result.Silent { if decoded.Silent != tt.result.Silent {
t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent)
} }
if decoded.IsError != tt.result.IsError { if decoded.IsError != tt.result.IsError {
t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError) t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError)
} }
if decoded.Async != tt.result.Async { if decoded.Async != tt.result.Async {
t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async) t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async)
} }
@ -193,27 +158,22 @@ func TestToolResultJSONSerialization(t *testing.T) {
func TestToolResultWithErrors(t *testing.T) { func TestToolResultWithErrors(t *testing.T) {
err := errors.New("underlying error") err := errors.New("underlying error")
result := ErrorResult("error message").WithError(err) result := ErrorResult("error message").WithError(err)
if result.Err == nil { if result.Err == nil {
t.Error("Expected Err to be set") t.Error("Expected Err to be set")
} }
if result.Err.Error() != "underlying error" { if result.Err.Error() != "underlying error" {
t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error()) t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error())
} }
// Verify Err is not serialized // Verify Err is not serialized
data, marshalErr := json.Marshal(result) data, marshalErr := json.Marshal(result)
if marshalErr != nil { if marshalErr != nil {
t.Fatalf("Failed to marshal: %v", marshalErr) t.Fatalf("Failed to marshal: %v", marshalErr)
} }
var decoded ToolResult var decoded ToolResult
if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil { if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil {
t.Fatalf("Failed to unmarshal: %v", unmarshalErr) t.Fatalf("Failed to unmarshal: %v", unmarshalErr)
} }
@ -232,47 +192,37 @@ func TestToolResultJSONStructure(t *testing.T) {
} }
// Verify JSON structure // Verify JSON structure
var parsed map[string]any var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil { if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to parse JSON: %v", err) t.Fatalf("Failed to parse JSON: %v", err)
} }
// Check expected keys exist // Check expected keys exist
if _, ok := parsed["for_llm"]; !ok { if _, ok := parsed["for_llm"]; !ok {
t.Error("Expected 'for_llm' key in JSON") t.Error("Expected 'for_llm' key in JSON")
} }
if _, ok := parsed["for_user"]; !ok { if _, ok := parsed["for_user"]; !ok {
t.Error("Expected 'for_user' key in JSON") t.Error("Expected 'for_user' key in JSON")
} }
if _, ok := parsed["silent"]; !ok { if _, ok := parsed["silent"]; !ok {
t.Error("Expected 'silent' key in JSON") t.Error("Expected 'silent' key in JSON")
} }
if _, ok := parsed["is_error"]; !ok { if _, ok := parsed["is_error"]; !ok {
t.Error("Expected 'is_error' key in JSON") t.Error("Expected 'is_error' key in JSON")
} }
if _, ok := parsed["async"]; !ok { if _, ok := parsed["async"]; !ok {
t.Error("Expected 'async' key in JSON") t.Error("Expected 'async' key in JSON")
} }
// Check that 'err' is NOT present (it should have json:"-" tag) // Check that 'err' is NOT present (it should have json:"-" tag)
if _, ok := parsed["err"]; ok { if _, ok := parsed["err"]; ok {
t.Error("Expected 'err' key to be excluded from JSON") t.Error("Expected 'err' key to be excluded from JSON")
} }
// Verify values // Verify values
if parsed["for_llm"] != "test content" { if parsed["for_llm"] != "test content" {
t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"]) t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"])
} }
if parsed["silent"] != false { if parsed["silent"] != false {
t.Errorf("Expected silent false, got %v", parsed["silent"]) t.Errorf("Expected silent false, got %v", parsed["silent"])
} }

896
pkg/tools/shell_ext_test.go Normal file
View file

@ -0,0 +1,896 @@
package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"time"
)
func TestGuardCommand_RelativePathWithSlashes(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
cmds := []string{
"pytest tests/cold/test_solver.py -v --tb=short",
"cd projects/terra-py-form && pytest",
"uv run pytest tests/cold/test_solver.py -v --tb=short",
"cat src/terra_py_form/cold/parser.py",
"python src/main.py --config config/dev.json",
}
for _, cmd := range cmds {
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Relative path should not be blocked: %q → %s", cmd, result)
}
}
}
func TestGuardCommand_VenvBinary(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
cmds := []string{
".venv/bin/python -m pytest",
".venv/bin/pytest tests/ -v",
".venv/bin/pip install -e .",
}
for _, cmd := range cmds {
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Venv relative path should not be blocked: %q → %s", cmd, result)
}
}
}
func TestGuardCommand_ExecutableBinaryAllowed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix executable permission test not applicable on Windows")
}
workspace := t.TempDir()
externalDir := t.TempDir()
execPath := filepath.Join(externalDir, "mybin")
os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0o755)
tool, _ := NewExecTool(workspace, true)
cmd := execPath + " --help"
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Executable binary outside workspace should be allowed: %q → %s", cmd, result)
}
}
func TestGuardCommand_ExecutableBinaryAllowed_Windows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-specific test")
}
workspace := t.TempDir()
externalDir := t.TempDir()
execPath := filepath.Join(externalDir, "tool.exe")
os.WriteFile(execPath, []byte("MZ"), 0o644)
tool, _ := NewExecTool(workspace, true)
cmd := execPath + " --version"
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Windows .exe outside workspace should be allowed: %q → %s", cmd, result)
}
}
func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission test not applicable on Windows")
}
workspace := t.TempDir()
externalDir := t.TempDir()
dataFile := filepath.Join(externalDir, "secret.txt")
os.WriteFile(dataFile, []byte("secret data"), 0o644)
tool, _ := NewExecTool(workspace, true)
cmd := "cat " + dataFile
result := tool.guardCommand(cmd, workspace)
if result == "" {
t.Errorf("Non-executable file outside workspace should be blocked: %q", cmd)
}
if !strings.Contains(result, "path outside working dir") {
t.Errorf("Expected 'path outside working dir' message, got: %s", result)
}
}
func TestGuardCommand_NonExistentAbsolutePathBlocked(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
var cmd string
if runtime.GOOS == "windows" {
cmd = "echo hello > C:\\nonexistent_picoclaw_test_output"
} else {
cmd = "echo hello > /tmp/nonexistent_picoclaw_test_output"
}
result := tool.guardCommand(cmd, workspace)
if result == "" {
t.Errorf("Non-existent absolute path outside workspace should be blocked: %q", cmd)
}
}
func TestGuardCommand_FlagEmbeddedPathSkipped(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
cmds := []string{
"gcc -I/usr/local/include -L/usr/lib main.c",
"g++ -std=c++17 -I/opt/include file.cpp",
"python --prefix=/usr/local script.py",
}
for _, cmd := range cmds {
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Flag-embedded path should not be blocked: %q → %s", cmd, result)
}
}
}
func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
innerDir := filepath.Join(workspace, "projects", "myapp")
os.MkdirAll(innerDir, 0o755)
cmd := "ls " + innerDir
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Absolute path inside workspace should be allowed: %q → %s", cmd, result)
}
}
func TestGuardCommand_PathTraversal(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
cmds := []string{
"cat ../../etc/passwd",
"cat ../../../etc/shadow",
"ls projects/../../../../etc",
}
for _, cmd := range cmds {
result := tool.guardCommand(cmd, workspace)
if result == "" {
t.Errorf("Path traversal should be blocked: %q", cmd)
}
if !strings.Contains(result, "path traversal") {
t.Errorf("Expected 'path traversal' message, got: %s", result)
}
}
}
func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) {
workspace := t.TempDir()
innerDir := filepath.Join(workspace, "projects", "foo")
os.MkdirAll(innerDir, 0o755)
tool, _ := NewExecTool(workspace, true)
cmd := "cd " + innerDir + " && ls -la"
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("cd to workspace subdir should be allowed: %q → %s", cmd, result)
}
}
func TestGuardCommand_AgentCLISlashCommand(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
cmds := []string{
`codex exec --yolo "/review skip-git-repo-check"`,
`claude "/review"`,
`gemini "/help"`,
}
for _, cmd := range cmds {
result := tool.guardCommand(cmd, workspace)
if result != "" {
t.Errorf("Agent CLI slash command should not be blocked: %q → %s", cmd, result)
}
}
if runtime.GOOS != "windows" {
blocked := `cat /etc/hosts`
result := tool.guardCommand(blocked, workspace)
if result == "" {
t.Errorf("Non-agent command with absolute path should be blocked: %q", blocked)
}
}
}
func TestGuardCommand_DenyPattern_IncludesPattern(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
tool.denyPatterns = append(tool.denyPatterns, regexp.MustCompile(`\bdangerous_cmd\b`))
result := tool.guardCommand("dangerous_cmd --force", workspace)
if result == "" {
t.Fatal("expected deny pattern to block the command")
}
if !strings.Contains(result, "deny pattern") {
t.Errorf("expected 'deny pattern' in message, got: %s", result)
}
if !strings.Contains(result, `\bdangerous_cmd\b`) {
t.Errorf("expected pattern string in message, got: %s", result)
}
}
func TestGuardCommand_Allowlist_ShowsRules(t *testing.T) {
workspace := t.TempDir()
tool, _ := NewExecTool(workspace, true)
tool.SetAllowRules([]string{"go test", "git"})
result := tool.guardCommand("curl http://example.com", workspace)
if result == "" {
t.Fatal("expected allowlist to block the command")
}
if !strings.Contains(result, "not in allowlist") {
t.Errorf("expected 'not in allowlist' in message, got: %s", result)
}
if !strings.Contains(result, "go test") || !strings.Contains(result, "git") {
t.Errorf("expected allowlist rules in message, got: %s", result)
}
}
func TestGuardCommand_PathOutside_IncludesPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix absolute path test not applicable on Windows")
}
workspace := t.TempDir()
externalDir := t.TempDir()
dataFile := filepath.Join(externalDir, "secret.txt")
os.WriteFile(dataFile, []byte("secret"), 0o644)
tool, _ := NewExecTool(workspace, true)
result := tool.guardCommand("cat "+dataFile, workspace)
if result == "" {
t.Fatal("expected path outside workspace to be blocked")
}
if !strings.Contains(result, "path outside working dir") {
t.Errorf("expected 'path outside working dir' in message, got: %s", result)
}
if !strings.Contains(result, dataFile) {
t.Errorf("expected offending path %q in message, got: %s", dataFile, result)
}
}
func TestExecTool_Bg_StartAndOutput(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
if runtime.GOOS == "windows" {
cmd = "Write-Output 'hello from bg'; Start-Sleep -Seconds 30"
} else {
cmd = "echo 'hello from bg'; sleep 30"
}
result := tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
if result.IsError {
t.Fatalf("failed to start bg process: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "bg-1") {
t.Errorf("expected bg-1 in result, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Background process started") {
t.Errorf("expected start message, got: %s", result.ForLLM)
}
outputResult := tool.Execute(context.Background(), map[string]any{
"bg_action": "output",
"bg_id": "bg-1",
})
if outputResult.IsError {
t.Fatalf("failed to get output: %s", outputResult.ForLLM)
}
if !strings.Contains(outputResult.ForLLM, "hello from bg") {
t.Errorf("expected 'hello from bg' in output, got: %s", outputResult.ForLLM)
}
if !strings.Contains(outputResult.ForLLM, "running") {
t.Errorf("expected 'running' status, got: %s", outputResult.ForLLM)
}
}
func TestExecTool_Bg_Kill(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
if runtime.GOOS == "windows" {
cmd = "Start-Sleep -Seconds 60"
} else {
cmd = "sleep 60"
}
result := tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
if result.IsError {
t.Fatalf("failed to start bg process: %s", result.ForLLM)
}
killResult := tool.Execute(context.Background(), map[string]any{
"bg_action": "kill",
"bg_id": "bg-1",
})
if killResult.IsError {
t.Fatalf("failed to kill: %s", killResult.ForLLM)
}
if !strings.Contains(killResult.ForLLM, "terminated") {
t.Errorf("expected 'terminated' message, got: %s", killResult.ForLLM)
}
procs := tool.BgProcesses()
if _, ok := procs["bg-1"]; ok {
t.Errorf("expected bg-1 to be removed after kill")
}
}
func TestExecTool_Bg_ExitedProcess(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
if runtime.GOOS == "windows" {
cmd = "Write-Output 'quick exit'"
} else {
cmd = "echo 'quick exit'"
}
result := tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
if result.IsError {
t.Fatalf("failed to start bg process: %s", result.ForLLM)
}
time.Sleep(4 * time.Second)
outputResult := tool.Execute(context.Background(), map[string]any{
"bg_action": "output",
"bg_id": "bg-1",
})
if outputResult.IsError {
t.Fatalf("failed to get output: %s", outputResult.ForLLM)
}
if !strings.Contains(outputResult.ForLLM, "exited") {
t.Errorf("expected 'exited' in output, got: %s", outputResult.ForLLM)
}
if !strings.Contains(outputResult.ForLLM, "quick exit") {
t.Errorf("expected 'quick exit' in output, got: %s", outputResult.ForLLM)
}
}
func TestExecTool_Bg_InvalidID(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
result := tool.Execute(context.Background(), map[string]any{
"bg_action": "output",
"bg_id": "bg-999",
})
if !result.IsError {
t.Fatalf("expected error for invalid bg_id")
}
if !strings.Contains(result.ForLLM, "not found") {
t.Errorf("expected 'not found' message, got: %s", result.ForLLM)
}
result = tool.Execute(context.Background(), map[string]any{
"bg_action": "kill",
"bg_id": "bg-999",
})
if !result.IsError {
t.Fatalf("expected error for invalid bg_id")
}
}
func TestExecTool_Bg_InitialOutputCapture(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
if runtime.GOOS == "windows" {
cmd = "Write-Output 'initial line 1'; Write-Output 'initial line 2'; Start-Sleep -Seconds 30"
} else {
cmd = "echo 'initial line 1'; echo 'initial line 2'; sleep 30"
}
result := tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
if result.IsError {
t.Fatalf("failed to start bg process: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "initial line 1") {
t.Errorf("expected 'initial line 1' in initial output, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "initial line 2") {
t.Errorf("expected 'initial line 2' in initial output, got: %s", result.ForLLM)
}
}
func TestExecTool_Bg_RuntimeStatus(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
if s := tool.RuntimeStatus(); s != "" {
t.Errorf("expected empty runtime status with no bg processes, got: %s", s)
}
var cmd string
if runtime.GOOS == "windows" {
cmd = "Start-Sleep -Seconds 30"
} else {
cmd = "sleep 30"
}
tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
status := tool.RuntimeStatus()
if !strings.Contains(status, "Background Processes") {
t.Errorf("expected 'Background Processes' section, got: %s", status)
}
if !strings.Contains(status, "bg-1") {
t.Errorf("expected 'bg-1' in status, got: %s", status)
}
if !strings.Contains(status, "running") {
t.Errorf("expected 'running' in status, got: %s", status)
}
}
func TestExecTool_Bg_Shutdown(t *testing.T) {
tool, _ := NewExecTool("", false)
var cmd string
if runtime.GOOS == "windows" {
cmd = "Start-Sleep -Seconds 60"
} else {
cmd = "sleep 60"
}
tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
procs := tool.BgProcesses()
for _, bp := range procs {
if !bp.isRunning() {
t.Errorf("expected process to be running before shutdown")
}
}
tool.Shutdown()
procs = tool.BgProcesses()
for _, bp := range procs {
if bp.isRunning() {
t.Errorf("expected process to be stopped after shutdown")
}
}
}
func TestRingBuffer(t *testing.T) {
t.Run("Write and String", func(t *testing.T) {
rb := newRingBuffer(100)
rb.Write([]byte("hello "))
rb.Write([]byte("world"))
if got := rb.String(); got != "hello world" {
t.Errorf("expected 'hello world', got %q", got)
}
})
t.Run("Lines", func(t *testing.T) {
rb := newRingBuffer(100)
rb.Write([]byte("line1\nline2\nline3\nline4\nline5\n"))
lines := rb.Lines(3)
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d", len(lines))
}
if lines[0] != "line3" || lines[1] != "line4" || lines[2] != "line5" {
t.Errorf("unexpected lines: %v", lines)
}
})
t.Run("Match", func(t *testing.T) {
rb := newRingBuffer(100)
rb.Write([]byte("starting...\nServer ready on port 3000\nwaiting...\n"))
re := regexp.MustCompile(`ready.*port`)
match := rb.Match(re)
if match == "" {
t.Fatal("expected match but got empty string")
}
if !strings.Contains(match, "ready") {
t.Errorf("expected match to contain 'ready', got: %s", match)
}
re2 := regexp.MustCompile(`never_match`)
match2 := rb.Match(re2)
if match2 != "" {
t.Errorf("expected no match, got: %s", match2)
}
})
t.Run("Overflow", func(t *testing.T) {
rb := newRingBuffer(10)
rb.Write([]byte("1234567890ABCDEF"))
got := rb.String()
if len(got) != 10 {
t.Errorf("expected buffer to be 10 bytes, got %d", len(got))
}
if got != "7890ABCDEF" {
t.Errorf("expected '7890ABCDEF', got %q", got)
}
})
t.Run("Len", func(t *testing.T) {
rb := newRingBuffer(100)
if rb.Len() != 0 {
t.Errorf("expected 0 length initially")
}
rb.Write([]byte("hello"))
if rb.Len() != 5 {
t.Errorf("expected 5, got %d", rb.Len())
}
})
t.Run("Empty Lines", func(t *testing.T) {
rb := newRingBuffer(100)
lines := rb.Lines(5)
if lines != nil {
t.Errorf("expected nil for empty buffer, got: %v", lines)
}
})
}
func TestExecTool_Bg_RingBufferOverflow(t *testing.T) {
tool, _ := NewExecTool("", false)
defer tool.Shutdown()
var cmd string
if runtime.GOOS == "windows" {
cmd = "1..2000 | ForEach-Object { Write-Output ('x' * 50) }; Start-Sleep -Seconds 30"
} else {
cmd = "yes 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | head -n 2000; sleep 30"
}
result := tool.Execute(context.Background(), map[string]any{
"command": cmd,
"background": true,
})
if result.IsError {
t.Fatalf("failed to start bg process: %s", result.ForLLM)
}
time.Sleep(5 * time.Second)
outputResult := tool.Execute(context.Background(), map[string]any{
"bg_action": "output",
"bg_id": "bg-1",
})
if outputResult.IsError {
t.Fatalf("failed to get output: %s", outputResult.ForLLM)
}
procs := tool.BgProcesses()
bp := procs["bg-1"]
if bp == nil {
t.Fatal("bg-1 not found")
}
bufLen := bp.output.Len()
if bufLen > bgRingBufSize {
t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize)
}
}
func TestIsLocalHost(t *testing.T) {
tests := []struct {
host string
want bool
}{
{"localhost", true},
{"LOCALHOST", true},
{"127.0.0.1", true},
{"127.0.0.2", true},
{"::1", true},
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.0.1", true},
{"192.168.1.100", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
{"example.com", false},
{"api.github.com", false},
{"172.15.255.255", false},
{"172.32.0.0", false},
}
for _, tt := range tests {
got := isLocalHost(tt.host)
if got != tt.want {
t.Errorf("isLocalHost(%q) = %v, want %v", tt.host, got, tt.want)
}
}
}
func TestCheckCurlLocalNet(t *testing.T) {
tests := []struct {
cmd string
wantErr bool
}{
{"curl http://localhost:3000/health", false},
{"curl -v http://127.0.0.1:8080/api/status", false},
{"wget http://192.168.1.10/file.bin", false},
{"curl -X POST http://10.0.0.5:9000/webhook", false},
{"curl http://example.com", true},
{"wget https://releases.github.com/v1.tar.gz", true},
{"curl http://8.8.8.8/data", true},
{"curl --help", false},
{"curl --version", false},
{"wget --help", false},
}
for _, tt := range tests {
errMsg := checkCurlLocalNet(tt.cmd)
gotErr := errMsg != ""
if gotErr != tt.wantErr {
t.Errorf("checkCurlLocalNet(%q): gotErr=%v wantErr=%v (msg: %q)",
tt.cmd, gotErr, tt.wantErr, errMsg)
}
}
}
func TestExecTool_LocalNetOnly(t *testing.T) {
tool, _ := NewExecTool("", false)
tool.SetLocalNetOnly(true)
tests := []struct {
cmd string
wantErr bool
}{
{"curl http://localhost:3000", false},
{"curl http://example.com", true},
{"echo hello", false},
}
ctx := context.Background()
for _, tt := range tests {
result := tool.Execute(ctx, map[string]any{"command": tt.cmd})
if tt.wantErr && !result.IsError {
t.Errorf("cmd %q: expected blocked, but succeeded", tt.cmd)
}
if !tt.wantErr && result.IsError && strings.Contains(result.ForLLM, "safety guard") {
t.Errorf("cmd %q: expected allowed, but safety guard blocked: %s", tt.cmd, result.ForLLM)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
package tools
import (
"os"
"strconv"
"strings"
"syscall"
)
func processRunning(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
if err != nil && err != syscall.EPERM {
return false
}
data, readErr := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if readErr != nil {
return false
}
raw := string(data)
end := strings.LastIndex(raw, ")")
if end == -1 || end+2 >= len(raw) {
return true
}
fields := strings.Fields(raw[end+2:])
if len(fields) == 0 {
return true
}
state := fields[0]
return state != "Z"
}

View file

@ -13,33 +13,12 @@ import (
"time" "time"
) )
func processRunning(pid int) bool { func processExists(pid int) bool {
if pid <= 0 { if pid <= 0 {
return false return false
} }
// kill(0) can return success for zombie processes too, so inspect /proc
// state and treat zombies as not-running for timeout cleanup assertions.
err := syscall.Kill(pid, 0) err := syscall.Kill(pid, 0)
if err != nil && err != syscall.EPERM { return err == nil || err == syscall.EPERM
return false
}
data, readErr := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if readErr != nil {
return false
}
raw := string(data)
end := strings.LastIndex(raw, ")")
if end == -1 || end+2 >= len(raw) {
return true // best effort fallback
}
fields := strings.Fields(raw[end+2:])
if len(fields) == 0 {
return true // best effort fallback
}
state := fields[0]
return state != "Z"
} }
func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
@ -47,12 +26,14 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("unable to configure exec tool: %s", err) t.Errorf("unable to configure exec tool: %s", err)
} }
tool.SetTimeout(500 * time.Millisecond) tool.SetTimeout(500 * time.Millisecond)
args := map[string]any{ args := map[string]any{
// Spawn a child process that would outlive the shell unless process-group kill is used. // Spawn a child process that would outlive the shell unless process-group kill is used.
"command": "sleep 60 & echo $! > child.pid; wait", "command": "sleep 60 & echo $! > child.pid; wait",
} }
result := tool.Execute(context.Background(), args) result := tool.Execute(context.Background(), args)
if !result.IsError { if !result.IsError {
t.Fatalf("expected timeout error, got success: %s", result.ForLLM) t.Fatalf("expected timeout error, got success: %s", result.ForLLM)
@ -66,6 +47,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read child pid file: %v", err) t.Fatalf("failed to read child pid file: %v", err)
} }
childPID, err := strconv.Atoi(strings.TrimSpace(string(data))) childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil { if err != nil {
t.Fatalf("failed to parse child pid: %v", err) t.Fatalf("failed to parse child pid: %v", err)
@ -73,10 +55,11 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if !processRunning(childPID) { if !processExists(childPID) {
return return
} }
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
t.Fatalf("child process %d is still running after timeout", childPID) t.Fatalf("child process %d is still running after timeout", childPID)
} }

View file

@ -14,29 +14,22 @@ import (
func TestInstallSkillToolName(t *testing.T) { func TestInstallSkillToolName(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
assert.Equal(t, "install_skill", tool.Name()) assert.Equal(t, "install_skill", tool.Name())
} }
func TestInstallSkillToolMissingSlug(t *testing.T) { func TestInstallSkillToolMissingSlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
result := tool.Execute(context.Background(), map[string]any{}) result := tool.Execute(context.Background(), map[string]any{})
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
} }
func TestInstallSkillToolEmptySlug(t *testing.T) { func TestInstallSkillToolEmptySlug(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"slug": " ", "slug": " ",
}) })
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
} }
@ -45,9 +38,7 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) {
cases := []string{ cases := []string{
"../etc/passwd", "../etc/passwd",
"path/traversal", "path/traversal",
"path\\traversal", "path\\traversal",
} }
@ -55,85 +46,59 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) {
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"slug": slug, "slug": slug,
}) })
assert.True(t, result.IsError, "slug %q should be rejected", slug) assert.True(t, result.IsError, "slug %q should be rejected", slug)
assert.Contains(t, result.ForLLM, "invalid slug") assert.Contains(t, result.ForLLM, "invalid slug")
} }
} }
func TestInstallSkillToolAlreadyExists(t *testing.T) { func TestInstallSkillToolAlreadyExists(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "existing-skill") skillDir := filepath.Join(workspace, "skills", "existing-skill")
require.NoError(t, os.MkdirAll(skillDir, 0o755)) require.NoError(t, os.MkdirAll(skillDir, 0o755))
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"slug": "existing-skill", "slug": "existing-skill",
"registry": "clawhub", "registry": "clawhub",
}) })
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "already installed") assert.Contains(t, result.ForLLM, "already installed")
} }
func TestInstallSkillToolRegistryNotFound(t *testing.T) { func TestInstallSkillToolRegistryNotFound(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"slug": "some-skill", "slug": "some-skill",
"registry": "nonexistent", "registry": "nonexistent",
}) })
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "registry") assert.Contains(t, result.ForLLM, "registry")
assert.Contains(t, result.ForLLM, "not found") assert.Contains(t, result.ForLLM, "not found")
} }
func TestInstallSkillToolParameters(t *testing.T) { func TestInstallSkillToolParameters(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
params := tool.Parameters() params := tool.Parameters()
props, ok := params["properties"].(map[string]any) props, ok := params["properties"].(map[string]any)
assert.True(t, ok) assert.True(t, ok)
assert.Contains(t, props, "slug") assert.Contains(t, props, "slug")
assert.Contains(t, props, "version") assert.Contains(t, props, "version")
assert.Contains(t, props, "registry") assert.Contains(t, props, "registry")
assert.Contains(t, props, "force") assert.Contains(t, props, "force")
required, ok := params["required"].([]string) required, ok := params["required"].([]string)
assert.True(t, ok) assert.True(t, ok)
assert.Contains(t, required, "slug") assert.Contains(t, required, "slug")
assert.Contains(t, required, "registry") assert.Contains(t, required, "registry")
} }
func TestInstallSkillToolMissingRegistry(t *testing.T) { func TestInstallSkillToolMissingRegistry(t *testing.T) {
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"slug": "some-skill", "slug": "some-skill",
}) })
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "invalid registry") assert.Contains(t, result.ForLLM, "invalid registry")
} }

View file

@ -11,81 +11,62 @@ import (
func TestFindSkillsToolName(t *testing.T) { func TestFindSkillsToolName(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
assert.Equal(t, "find_skills", tool.Name()) assert.Equal(t, "find_skills", tool.Name())
} }
func TestFindSkillsToolMissingQuery(t *testing.T) { func TestFindSkillsToolMissingQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
result := tool.Execute(context.Background(), map[string]any{}) result := tool.Execute(context.Background(), map[string]any{})
assert.True(t, result.IsError) assert.True(t, result.IsError)
assert.Contains(t, result.ForLLM, "query is required") assert.Contains(t, result.ForLLM, "query is required")
} }
func TestFindSkillsToolEmptyQuery(t *testing.T) { func TestFindSkillsToolEmptyQuery(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"query": " ", "query": " ",
}) })
assert.True(t, result.IsError) assert.True(t, result.IsError)
} }
func TestFindSkillsToolCacheHit(t *testing.T) { func TestFindSkillsToolCacheHit(t *testing.T) {
cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min
cache.Put("github", []skills.SearchResult{ cache.Put("github", []skills.SearchResult{
{Slug: "github", Score: 0.9, RegistryName: "clawhub"}, {Slug: "github", Score: 0.9, RegistryName: "clawhub"},
}) })
tool := NewFindSkillsTool(skills.NewRegistryManager(), cache) tool := NewFindSkillsTool(skills.NewRegistryManager(), cache)
result := tool.Execute(context.Background(), map[string]any{ result := tool.Execute(context.Background(), map[string]any{
"query": "github", "query": "github",
}) })
assert.False(t, result.IsError) assert.False(t, result.IsError)
assert.Contains(t, result.ForLLM, "github") assert.Contains(t, result.ForLLM, "github")
assert.Contains(t, result.ForLLM, "cached") assert.Contains(t, result.ForLLM, "cached")
} }
func TestFindSkillsToolParameters(t *testing.T) { func TestFindSkillsToolParameters(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
params := tool.Parameters() params := tool.Parameters()
props, ok := params["properties"].(map[string]any) props, ok := params["properties"].(map[string]any)
assert.True(t, ok) assert.True(t, ok)
assert.Contains(t, props, "query") assert.Contains(t, props, "query")
assert.Contains(t, props, "limit") assert.Contains(t, props, "limit")
required, ok := params["required"].([]string) required, ok := params["required"].([]string)
assert.True(t, ok) assert.True(t, ok)
assert.Contains(t, required, "query") assert.Contains(t, required, "query")
} }
func TestFindSkillsToolDescription(t *testing.T) { func TestFindSkillsToolDescription(t *testing.T) {
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
assert.NotEmpty(t, tool.Description()) assert.NotEmpty(t, tool.Description())
assert.Contains(t, tool.Description(), "skill") assert.Contains(t, tool.Description(), "skill")
} }
func TestFormatSearchResultsEmpty(t *testing.T) { func TestFormatSearchResultsEmpty(t *testing.T) {
result := formatSearchResults("test query", nil, false) result := formatSearchResults("test query", nil, false)
assert.Contains(t, result, "No skills found") assert.Contains(t, result, "No skills found")
} }
@ -93,28 +74,17 @@ func TestFormatSearchResultsWithData(t *testing.T) {
results := []skills.SearchResult{ results := []skills.SearchResult{
{ {
Slug: "github", Slug: "github",
Score: 0.95, Score: 0.95,
DisplayName: "GitHub", DisplayName: "GitHub",
Summary: "GitHub API integration", Summary: "GitHub API integration",
Version: "1.0.0", Version: "1.0.0",
RegistryName: "clawhub", RegistryName: "clawhub",
}, },
} }
output := formatSearchResults("github", results, false) output := formatSearchResults("github", results, false)
assert.Contains(t, output, "github") assert.Contains(t, output, "github")
assert.Contains(t, output, "v1.0.0") assert.Contains(t, output, "v1.0.0")
assert.Contains(t, output, "0.950") assert.Contains(t, output, "0.950")
assert.Contains(t, output, "clawhub") assert.Contains(t, output, "clawhub")
assert.Contains(t, output, "install_skill") assert.Contains(t, output, "install_skill")
} }

View file

@ -8,43 +8,33 @@ import (
func TestSpawnTool_Execute_EmptyTask(t *testing.T) { func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{})
tool := NewSpawnTool(manager) tool := NewSpawnTool(manager)
ctx := context.Background() ctx := context.Background()
tests := []struct { tests := []struct {
name string name string
args map[string]any args map[string]any
}{ }{
{"empty string", map[string]any{"task": ""}}, {"empty string", map[string]any{"task": ""}},
{"whitespace only", map[string]any{"task": " "}}, {"whitespace only", map[string]any{"task": " "}},
{"tabs and newlines", map[string]any{"task": "\t\n "}}, {"tabs and newlines", map[string]any{"task": "\t\n "}},
{"missing task key", map[string]any{"label": "test"}}, {"missing task key", map[string]any{"label": "test"}},
{"wrong type", map[string]any{"task": 123}}, {"wrong type", map[string]any{"task": 123}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := tool.Execute(ctx, tt.args) result := tool.Execute(ctx, tt.args)
if result == nil { if result == nil {
t.Fatal("Result should not be nil") t.Fatal("Result should not be nil")
} }
if !result.IsError { if !result.IsError {
t.Error("Expected error for invalid task parameter") t.Error("Expected error for invalid task parameter")
} }
if !strings.Contains(result.ForLLM, "task is required") {
if !strings.Contains(result.ForLLM, `"task"`) { t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
t.Errorf("Error message should mention '\"task\"', got: %s", result.ForLLM)
} }
}) })
} }
@ -52,29 +42,22 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
func TestSpawnTool_Execute_ValidTask(t *testing.T) { func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{})
tool := NewSpawnTool(manager) tool := NewSpawnTool(manager)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"task": "Write a haiku about coding", "task": "Write a haiku about coding",
"label": "haiku-task", "label": "haiku-task",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
if result == nil { if result == nil {
t.Fatal("Result should not be nil") t.Fatal("Result should not be nil")
} }
if result.IsError { if result.IsError {
t.Errorf("Expected success for valid task, got error: %s", result.ForLLM) t.Errorf("Expected success for valid task, got error: %s", result.ForLLM)
} }
if !result.Async { if !result.Async {
t.Error("SpawnTool should return async result") t.Error("SpawnTool should return async result")
} }
@ -84,16 +67,13 @@ func TestSpawnTool_Execute_NilManager(t *testing.T) {
tool := NewSpawnTool(nil) tool := NewSpawnTool(nil)
ctx := context.Background() ctx := context.Background()
args := map[string]any{"task": "test task"} args := map[string]any{"task": "test task"}
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
if !result.IsError { if !result.IsError {
t.Error("Expected error for nil manager") t.Error("Expected error for nil manager")
} }
if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
if !strings.Contains(result.ForLLM, "spawn tool is not available") { t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
t.Errorf("Error message should mention spawn tool not available, got: %s", result.ForLLM)
} }
} }

View file

@ -0,0 +1,102 @@
package tools
import (
"context"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/orch"
"testing"
"time"
)
func TestSubagentTool_SetContext(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager)
tool.SetContext("test-channel", "test-chat")
}
func TestFormatToolStats(t *testing.T) {
tests := []struct {
name string
stats map[string]int
want string
}{
{"empty", map[string]int{}, ""},
{"single", map[string]int{"exec": 3}, "exec:3"},
{
"multiple sorted",
map[string]int{"read_file": 5, "exec": 3, "write_file": 1},
"exec:3,read_file:5,write_file:1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatToolStats(tt.stats)
if got != tt.want {
t.Errorf("formatToolStats(%v) = %q, want %q", tt.stats, got, tt.want)
}
})
}
}
func TestSubagentManager_Spawn_SetsMetadata(t *testing.T) {
provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus()
mgr := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{})
_, err := mgr.Spawn(
context.Background(),
"say hello", "meta-test", "", "cli", "direct", "",
nil,
)
if err != nil {
t.Fatalf("Spawn() error: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
received, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("timed out waiting for bus message")
}
if received.Channel != "system" {
t.Fatalf("expected channel 'system', got %q", received.Channel)
}
if received.Metadata == nil {
t.Fatal("Metadata should not be nil")
}
if received.Metadata["iterations"] != "1" {
t.Errorf("iterations = %q, want %q", received.Metadata["iterations"], "1")
}
if received.Metadata["tool_calls"] != "0" {
t.Errorf("tool_calls = %q, want %q", received.Metadata["tool_calls"], "0")
}
if received.Metadata["duration_ms"] == "" {
t.Error("duration_ms should be present")
}
}

View file

@ -4,34 +4,24 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// MockLLMProvider is a test implementation of LLMProvider // MockLLMProvider is a test implementation of LLMProvider
type MockLLMProvider struct { type MockLLMProvider struct {
lastOptions map[string]any lastOptions map[string]any
} }
func (m *MockLLMProvider) Chat( func (m *MockLLMProvider) Chat(
ctx context.Context, ctx context.Context,
messages []providers.Message, messages []providers.Message,
tools []providers.ToolDefinition, tools []providers.ToolDefinition,
model string, model string,
options map[string]any, options map[string]any,
) (*providers.LLMResponse, error) { ) (*providers.LLMResponse, error) {
m.lastOptions = options m.lastOptions = options
// Find the last user message to generate a response // Find the last user message to generate a response
for i := len(messages) - 1; i >= 0; i-- { for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" { if messages[i].Role == "user" {
return &providers.LLMResponse{ return &providers.LLMResponse{
@ -39,7 +29,6 @@ func (m *MockLLMProvider) Chat(
}, nil }, nil
} }
} }
return &providers.LLMResponse{Content: "No task provided"}, nil return &providers.LLMResponse{Content: "No task provided"}, nil
} }
@ -57,19 +46,12 @@ func (m *MockLLMProvider) GetContextWindow() int {
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
manager.SetLLMOptions(2048, 0.6) manager.SetLLMOptions(2048, 0.6)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("cli", "direct") ctx := WithToolContext(context.Background(), "cli", "direct")
ctx := context.Background()
args := map[string]any{"task": "Do something"} args := map[string]any{"task": "Do something"}
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
if result == nil || result.IsError { if result == nil || result.IsError {
@ -79,23 +61,18 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
if provider.lastOptions == nil { if provider.lastOptions == nil {
t.Fatal("Expected LLM options to be passed, got nil") t.Fatal("Expected LLM options to be passed, got nil")
} }
if provider.lastOptions["max_tokens"] != 2048 { if provider.lastOptions["max_tokens"] != 2048 {
t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048) t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048)
} }
if provider.lastOptions["temperature"] != 0.6 { if provider.lastOptions["temperature"] != 0.6 {
t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6) t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6)
} }
} }
// TestSubagentTool_Name verifies tool name // TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) { func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
if tool.Name() != "subagent" { if tool.Name() != "subagent" {
@ -104,198 +81,131 @@ func TestSubagentTool_Name(t *testing.T) {
} }
// TestSubagentTool_Description verifies tool description // TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) { func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
desc := tool.Description() desc := tool.Description()
if desc == "" { if desc == "" {
t.Error("Description should not be empty") t.Error("Description should not be empty")
} }
if !strings.Contains(desc, "subagent") {
if !strings.Contains(desc, "BLOCK") { t.Errorf("Description should mention 'subagent', got: %s", desc)
t.Errorf("Description should mention 'BLOCK', got: %s", desc)
}
if !strings.Contains(desc, "spawn") {
t.Errorf("Description should contrast with spawn, got: %s", desc)
} }
} }
// TestSubagentTool_Parameters verifies tool parameters schema // TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) { func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
params := tool.Parameters() params := tool.Parameters()
if params == nil { if params == nil {
t.Error("Parameters should not be nil") t.Error("Parameters should not be nil")
} }
// Check type // Check type
if params["type"] != "object" { if params["type"] != "object" {
t.Errorf("Expected type 'object', got: %v", params["type"]) t.Errorf("Expected type 'object', got: %v", params["type"])
} }
// Check properties // Check properties
props, ok := params["properties"].(map[string]any) props, ok := params["properties"].(map[string]any)
if !ok { if !ok {
t.Fatal("Properties should be a map") t.Fatal("Properties should be a map")
} }
// Verify task parameter // Verify task parameter
task, ok := props["task"].(map[string]any) task, ok := props["task"].(map[string]any)
if !ok { if !ok {
t.Fatal("Task parameter should exist") t.Fatal("Task parameter should exist")
} }
if task["type"] != "string" { if task["type"] != "string" {
t.Errorf("Task type should be 'string', got: %v", task["type"]) t.Errorf("Task type should be 'string', got: %v", task["type"])
} }
// Verify label parameter // Verify label parameter
label, ok := props["label"].(map[string]any) label, ok := props["label"].(map[string]any)
if !ok { if !ok {
t.Fatal("Label parameter should exist") t.Fatal("Label parameter should exist")
} }
if label["type"] != "string" { if label["type"] != "string" {
t.Errorf("Label type should be 'string', got: %v", label["type"]) t.Errorf("Label type should be 'string', got: %v", label["type"])
} }
// Check required fields // Check required fields
required, ok := params["required"].([]string) required, ok := params["required"].([]string)
if !ok { if !ok {
t.Fatal("Required should be a string array") t.Fatal("Required should be a string array")
} }
if len(required) != 1 || required[0] != "task" { if len(required) != 1 || required[0] != "task" {
t.Errorf("Required should be ['task'], got: %v", required) t.Errorf("Required should be ['task'], got: %v", required)
} }
} }
// TestSubagentTool_SetContext verifies context setting
func TestSubagentTool_SetContext(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager)
tool.SetContext("test-channel", "test-chat")
// Verify context is set (we can't directly access private fields,
// but we can verify it doesn't crash)
// The actual context usage is tested in Execute tests
}
// TestSubagentTool_Execute_Success tests successful execution // TestSubagentTool_Execute_Success tests successful execution
func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("telegram", "chat-123") ctx := WithToolContext(context.Background(), "telegram", "chat-123")
ctx := context.Background()
args := map[string]any{ args := map[string]any{
"task": "Write a haiku about coding", "task": "Write a haiku about coding",
"label": "haiku-task", "label": "haiku-task",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Verify basic ToolResult structure // Verify basic ToolResult structure
if result == nil { if result == nil {
t.Fatal("Result should not be nil") t.Fatal("Result should not be nil")
} }
// Verify no error // Verify no error
if result.IsError { if result.IsError {
t.Errorf("Expected success, got error: %s", result.ForLLM) t.Errorf("Expected success, got error: %s", result.ForLLM)
} }
// Verify not async // Verify not async
if result.Async { if result.Async {
t.Error("SubagentTool should be synchronous, not async") t.Error("SubagentTool should be synchronous, not async")
} }
// Verify not silent // Verify not silent
if result.Silent { if result.Silent {
t.Error("SubagentTool should not be silent") t.Error("SubagentTool should not be silent")
} }
// Verify ForUser contains brief summary (not empty) // Verify ForUser contains brief summary (not empty)
if result.ForUser == "" { if result.ForUser == "" {
t.Error("ForUser should contain result summary") t.Error("ForUser should contain result summary")
} }
if !strings.Contains(result.ForUser, "Task completed") { if !strings.Contains(result.ForUser, "Task completed") {
t.Errorf("ForUser should contain task completion, got: %s", result.ForUser) t.Errorf("ForUser should contain task completion, got: %s", result.ForUser)
} }
// Verify ForLLM contains full details // Verify ForLLM contains full details
if result.ForLLM == "" { if result.ForLLM == "" {
t.Error("ForLLM should contain full details") t.Error("ForLLM should contain full details")
} }
if !strings.Contains(result.ForLLM, "haiku-task") { if !strings.Contains(result.ForLLM, "haiku-task") {
t.Errorf("ForLLM should contain label 'haiku-task', got: %s", result.ForLLM) t.Errorf("ForLLM should contain label 'haiku-task', got: %s", result.ForLLM)
} }
if !strings.Contains(result.ForLLM, "Task completed:") { if !strings.Contains(result.ForLLM, "Task completed:") {
t.Errorf("ForLLM should contain task result, got: %s", result.ForLLM) t.Errorf("ForLLM should contain task result, got: %s", result.ForLLM)
} }
} }
// TestSubagentTool_Execute_NoLabel tests execution without label // TestSubagentTool_Execute_NoLabel tests execution without label
func TestSubagentTool_Execute_NoLabel(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"task": "Test task without label", "task": "Test task without label",
} }
@ -307,23 +217,18 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
} }
// ForLLM should show (unnamed) for missing label // ForLLM should show (unnamed) for missing label
if !strings.Contains(result.ForLLM, "(unnamed)") { if !strings.Contains(result.ForLLM, "(unnamed)") {
t.Errorf("ForLLM should show '(unnamed)' for missing label, got: %s", result.ForLLM) t.Errorf("ForLLM should show '(unnamed)' for missing label, got: %s", result.ForLLM)
} }
} }
// TestSubagentTool_Execute_MissingTask tests error handling for missing task // TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) { func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"label": "test", "label": "test",
} }
@ -331,35 +236,26 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error // Should return error
if !result.IsError { if !result.IsError {
t.Error("Expected error for missing task parameter") t.Error("Expected error for missing task parameter")
} }
// ForLLM should contain helpful error with example // ForLLM should contain error message
if !strings.Contains(result.ForLLM, "task is required") {
if !strings.Contains(result.ForLLM, `"task"`) { t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
t.Errorf("Error message should mention '\"task\"', got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Example") {
t.Errorf("Error message should include usage example, got: %s", result.ForLLM)
} }
// Err should be set // Err should be set
if result.Err == nil { if result.Err == nil {
t.Error("Err should be set for validation failure") t.Error("Err should be set for validation failure")
} }
} }
// TestSubagentTool_Execute_NilManager tests error handling for nil manager // TestSubagentTool_Execute_NilManager tests error handling for nil manager
func TestSubagentTool_Execute_NilManager(t *testing.T) { func TestSubagentTool_Execute_NilManager(t *testing.T) {
tool := NewSubagentTool(nil) tool := NewSubagentTool(nil)
ctx := context.Background() ctx := context.Background()
args := map[string]any{ args := map[string]any{
"task": "test task", "task": "test task",
} }
@ -367,37 +263,24 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should return error // Should return error
if !result.IsError { if !result.IsError {
t.Error("Expected error for nil manager") t.Error("Expected error for nil manager")
} }
if !strings.Contains(result.ForLLM, "not available in this session") { if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
t.Errorf("Error message should mention 'not available in this session', got: %s", result.ForLLM) t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
} }
} }
// TestSubagentTool_Execute_ContextPassing verifies context is properly used // TestSubagentTool_Execute_ContextPassing verifies context is properly used
func TestSubagentTool_Execute_ContextPassing(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
// Set context
channel := "test-channel" channel := "test-channel"
chatID := "test-chat" chatID := "test-chat"
ctx := WithToolContext(context.Background(), channel, chatID)
tool.SetContext(channel, chatID)
ctx := context.Background()
args := map[string]any{ args := map[string]any{
"task": "Test context passing", "task": "Test context passing",
} }
@ -405,144 +288,40 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// Should succeed // Should succeed
if result.IsError { if result.IsError {
t.Errorf("Expected success with context, got error: %s", result.ForLLM) t.Errorf("Expected success with context, got error: %s", result.ForLLM)
} }
// The context is used internally; we can't directly test it // The context is used internally; we can't directly test it
// but execution success indicates context was handled properly // but execution success indicates context was handled properly
} }
// TestSubagentTool_ForUserTruncation verifies long content is truncated for user // TestSubagentTool_ForUserTruncation verifies long content is truncated for user
func TestSubagentTool_ForUserTruncation(t *testing.T) { func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content // Create a mock provider that returns very long content
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{})
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
// Create a task that will generate long response // Create a task that will generate long response
longTask := strings.Repeat("This is a very long task description. ", 100) longTask := strings.Repeat("This is a very long task description. ", 100)
args := map[string]any{ args := map[string]any{
"task": longTask, "task": longTask,
"label": "long-test", "label": "long-test",
} }
result := tool.Execute(ctx, args) result := tool.Execute(ctx, args)
// ForUser should be truncated to 500 chars + "..." // ForUser should be truncated to 500 chars + "..."
maxUserLen := 500 maxUserLen := 500
if len(result.ForUser) > maxUserLen+3 { // +3 for "..." if len(result.ForUser) > maxUserLen+3 { // +3 for "..."
t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser)) t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser))
} }
// ForLLM should have full content // ForLLM should have full content
if !strings.Contains(result.ForLLM, longTask[:50]) { if !strings.Contains(result.ForLLM, longTask[:50]) {
t.Error("ForLLM should contain reference to original task") t.Error("ForLLM should contain reference to original task")
} }
} }
func TestFormatToolStats(t *testing.T) {
tests := []struct {
name string
stats map[string]int
want string
}{
{"empty", map[string]int{}, ""},
{"single", map[string]int{"exec": 3}, "exec:3"},
{
"multiple sorted",
map[string]int{"read_file": 5, "exec": 3, "write_file": 1},
"exec:3,read_file:5,write_file:1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatToolStats(tt.stats)
if got != tt.want {
t.Errorf("formatToolStats(%v) = %q, want %q", tt.stats, got, tt.want)
}
})
}
}
// TestSubagentManager_Spawn_SetsMetadata verifies that the bus message from a
// completed spawn includes execution statistics in Metadata.
func TestSubagentManager_Spawn_SetsMetadata(t *testing.T) {
provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus()
mgr := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{})
_, err := mgr.Spawn(
context.Background(),
"say hello", "meta-test", "", "cli", "direct", "",
nil,
)
if err != nil {
t.Fatalf("Spawn() error: %v", err)
}
// Consume the inbound message from the bus
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
received, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("timed out waiting for bus message")
}
if received.Channel != "system" {
t.Fatalf("expected channel 'system', got %q", received.Channel)
}
if received.Metadata == nil {
t.Fatal("Metadata should not be nil")
}
if received.Metadata["iterations"] != "1" {
t.Errorf("iterations = %q, want %q", received.Metadata["iterations"], "1")
}
if received.Metadata["tool_calls"] != "0" {
t.Errorf("tool_calls = %q, want %q", received.Metadata["tool_calls"], "0")
}
// duration_ms should be a non-negative number
if received.Metadata["duration_ms"] == "" {
t.Error("duration_ms should be present")
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,179 @@
package utils
import (
"strings"
"testing"
)
func TestStripThinkBlocks_ClosedBlock(t *testing.T) {
in := "<think>\nsecret reasoning\n</think>\n\nVisible content"
got := StripThinkBlocks(in)
if got != "Visible content" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "Visible content")
}
}
func TestStripThinkBlocks_UnclosedBlock(t *testing.T) {
in := "<think>reasoning that never ends\nmore reasoning"
got := StripThinkBlocks(in)
if got != "" {
t.Fatalf("StripThinkBlocks() = %q, want empty", got)
}
}
func TestStripThinkBlocks_MultipleBlocks(t *testing.T) {
in := "<think>first</think>middle<think>second</think>end"
got := StripThinkBlocks(in)
if got != "middleend" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middleend")
}
}
func TestStripThinkBlocks_NoBlocks(t *testing.T) {
in := "plain text without think blocks"
got := StripThinkBlocks(in)
if got != in {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, in)
}
}
func TestStripThinkBlocks_CaseInsensitive(t *testing.T) {
in := "<THINK>upper case</THINK>visible"
got := StripThinkBlocks(in)
if got != "visible" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "visible")
}
}
func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) {
in := "<think>closed</think>middle<think>unclosed tail"
got := StripThinkBlocks(in)
if got != "middle" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middle")
}
}
func TestDetectRepetitionLoop_HighRepetition(t *testing.T) {
phrase := "結構本格的なコード"
repeated := strings.Repeat(phrase, 300)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for highly repetitive text")
}
}
func TestDetectRepetitionLoop_NormalText(t *testing.T) {
normal := "The quick brown fox jumps over the lazy dog. " +
"Pack my box with five dozen liquor jugs. " +
"How vexingly quick daft zebras jump. " +
"Sphinx of black quartz, judge my vow. " +
"Two driven jocks help fax my big quiz. " +
"The five boxing wizards jump quickly. " +
"Jackdaws love my big sphinx of quartz. " +
"Grumpy wizards make a toxic brew for the jovial queen."
long := strings.Repeat(normal+" ", 10)
if DetectRepetitionLoop(long) {
t.Fatal("DetectRepetitionLoop should return false for normal text")
}
}
func TestDetectRepetitionLoop_ShortText(t *testing.T) {
if DetectRepetitionLoop("short") {
t.Fatal("DetectRepetitionLoop should return false for short text")
}
}
func TestDetectRepetitionLoop_EmptyString(t *testing.T) {
if DetectRepetitionLoop("") {
t.Fatal("DetectRepetitionLoop should return false for empty string")
}
}
func TestDetectRepetitionLoop_SingleCharRepeat(t *testing.T) {
repeated := strings.Repeat("あ", 2500)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for single-char repetition")
}
}
func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) {
phrase := "abcdefghij"
repeated := strings.Repeat(phrase, 50)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for repetitive text below sample size")
}
}
func TestTailPad_FewerThanN(t *testing.T) {
got := TailPad("a\nb", 5, 80)
lines := strings.Split(got, "\n")
if len(lines) != 5 {
t.Fatalf("TailPad line count = %d, want 5", len(lines))
}
for i := 0; i < 3; i++ {
if lines[i] != "\u2800" {
t.Errorf("TailPad line %d = %q, want padding", i, lines[i])
}
}
if lines[3] != "a" || lines[4] != "b" {
t.Errorf("TailPad content = %q %q, want a b", lines[3], lines[4])
}
}
func TestTailPad_ExactlyN(t *testing.T) {
in := "a\nb\nc"
got := TailPad(in, 3, 80)
if got != in {
t.Fatalf("TailPad exact = %q, want %q", got, in)
}
}
func TestTailPad_MoreThanN(t *testing.T) {
got := TailPad("a\nb\nc\nd\ne", 3, 80)
if got != "c\nd\ne" {
t.Fatalf("TailPad tail = %q, want %q", got, "c\nd\ne")
}
}
func TestTailPad_Empty(t *testing.T) {
got := TailPad("", 4, 80)
lines := strings.Split(got, "\n")
if len(lines) != 4 {
t.Fatalf("TailPad empty line count = %d, want 4", len(lines))
}
for i, l := range lines {
if i == len(lines)-1 {
if l != "" {
t.Errorf("TailPad empty last line = %q, want empty", l)
}
} else if l != "\u2800" {
t.Errorf("TailPad empty line %d = %q, want padding", i, l)
}
}
}
func TestTailPad_LongLineWraps(t *testing.T) {
got := TailPad("abcdefghij", 4, 5)
lines := strings.Split(got, "\n")
if len(lines) != 4 {
t.Fatalf("TailPad wrap line count = %d, want 4", len(lines))
}
if lines[2] != "abcde" || lines[3] != "fghij" {
t.Errorf("TailPad wrap content = %v", lines)
}
}
func TestTailPad_WrapPushesOldLines(t *testing.T) {
got := TailPad("short\nabcdefghij", 2, 5)
if got != "abcde\nfghij" {
t.Fatalf("TailPad wrap push = %q, want %q", got, "abcde\nfghij")
}
}

View file

@ -1,191 +1,6 @@
package utils package utils
import ( import "testing"
"strings"
"testing"
)
// --- StripThinkBlocks ---
func TestStripThinkBlocks_ClosedBlock(t *testing.T) {
in := "<think>\nsecret reasoning\n</think>\n\nVisible content"
got := StripThinkBlocks(in)
if got != "Visible content" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "Visible content")
}
}
func TestStripThinkBlocks_UnclosedBlock(t *testing.T) {
in := "<think>reasoning that never ends\nmore reasoning"
got := StripThinkBlocks(in)
if got != "" {
t.Fatalf("StripThinkBlocks() = %q, want empty", got)
}
}
func TestStripThinkBlocks_MultipleBlocks(t *testing.T) {
in := "<think>first</think>middle<think>second</think>end"
got := StripThinkBlocks(in)
if got != "middleend" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middleend")
}
}
func TestStripThinkBlocks_NoBlocks(t *testing.T) {
in := "plain text without think blocks"
got := StripThinkBlocks(in)
if got != in {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, in)
}
}
func TestStripThinkBlocks_CaseInsensitive(t *testing.T) {
in := "<THINK>upper case</THINK>visible"
got := StripThinkBlocks(in)
if got != "visible" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "visible")
}
}
func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) {
in := "<think>closed</think>middle<think>unclosed tail"
got := StripThinkBlocks(in)
if got != "middle" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middle")
}
}
// --- DetectRepetitionLoop ---
func TestDetectRepetitionLoop_HighRepetition(t *testing.T) {
// Repeat a short phrase many times → should be detected
phrase := "結構本格的なコード" //nolint:gosmopolitan // CJK test data
repeated := strings.Repeat(phrase, 300)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for highly repetitive text")
}
}
func TestDetectRepetitionLoop_NormalText(t *testing.T) {
// Normal varied text should not trigger
normal := "The quick brown fox jumps over the lazy dog. " +
"Pack my box with five dozen liquor jugs. " +
"How vexingly quick daft zebras jump. " +
"Sphinx of black quartz, judge my vow. " +
"Two driven jocks help fax my big quiz. " +
"The five boxing wizards jump quickly. " +
"Jackdaws love my big sphinx of quartz. " +
"Grumpy wizards make a toxic brew for the jovial queen."
// Extend to be long enough
long := strings.Repeat(normal+" ", 10)
if DetectRepetitionLoop(long) {
t.Fatal("DetectRepetitionLoop should return false for normal text")
}
}
func TestDetectRepetitionLoop_ShortText(t *testing.T) {
// Text shorter than N-gram size should never trigger
if DetectRepetitionLoop("short") {
t.Fatal("DetectRepetitionLoop should return false for short text")
}
}
func TestDetectRepetitionLoop_EmptyString(t *testing.T) {
if DetectRepetitionLoop("") {
t.Fatal("DetectRepetitionLoop should return false for empty string")
}
}
func TestDetectRepetitionLoop_SingleCharRepeat(t *testing.T) {
// "aaaa..." repeated → only 1 unique N-gram → detected
repeated := strings.Repeat("あ", 2500)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for single-char repetition")
}
}
func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) {
// Repetitive but under sample size still detected
phrase := "abcdefghij"
repeated := strings.Repeat(phrase, 50) // 500 chars
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for repetitive text below sample size")
}
}
// --- TailPad ---
func TestTailPad_FewerThanN(t *testing.T) {
got := TailPad("a\nb", 5, 80)
lines := strings.Split(got, "\n")
if len(lines) != 5 {
t.Fatalf("TailPad line count = %d, want 5", len(lines))
}
for i := 0; i < 3; i++ {
if lines[i] != "\u2800" {
t.Errorf("TailPad line %d = %q, want padding", i, lines[i])
}
}
if lines[3] != "a" || lines[4] != "b" {
t.Errorf("TailPad content = %q %q, want a b", lines[3], lines[4])
}
}
func TestTailPad_ExactlyN(t *testing.T) {
in := "a\nb\nc"
got := TailPad(in, 3, 80)
if got != in {
t.Fatalf("TailPad exact = %q, want %q", got, in)
}
}
func TestTailPad_MoreThanN(t *testing.T) {
got := TailPad("a\nb\nc\nd\ne", 3, 80)
if got != "c\nd\ne" {
t.Fatalf("TailPad tail = %q, want %q", got, "c\nd\ne")
}
}
func TestTailPad_Empty(t *testing.T) {
got := TailPad("", 4, 80)
lines := strings.Split(got, "\n")
if len(lines) != 4 {
t.Fatalf("TailPad empty line count = %d, want 4", len(lines))
}
for i, l := range lines {
if i == len(lines)-1 {
if l != "" {
t.Errorf("TailPad empty last line = %q, want empty", l)
}
} else if l != "\u2800" {
t.Errorf("TailPad empty line %d = %q, want padding", i, l)
}
}
}
func TestTailPad_LongLineWraps(t *testing.T) {
// One 10-char line wraps into 2 visual lines at width 5.
got := TailPad("abcdefghij", 4, 5)
lines := strings.Split(got, "\n")
if len(lines) != 4 {
t.Fatalf("TailPad wrap line count = %d, want 4", len(lines))
}
// 2 padding + "abcde" + "fghij"
if lines[2] != "abcde" || lines[3] != "fghij" {
t.Errorf("TailPad wrap content = %v", lines)
}
}
func TestTailPad_WrapPushesOldLines(t *testing.T) {
// "short" (1 visual) + "abcdefghij" (2 visual at width 5) = 3 visual.
// With n=2, only tail 2 visual lines remain.
got := TailPad("short\nabcdefghij", 2, 5)
if got != "abcde\nfghij" {
t.Fatalf("TailPad wrap push = %q, want %q", got, "abcde\nfghij")
}
}
// --- Truncate ---
func TestTruncate(t *testing.T) { func TestTruncate(t *testing.T) {
tests := []struct { tests := []struct {