first commit
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// newTestBlackboard builds a BlackboardTool over a fresh temp dir and a helper
|
||||
// to run an action and return the text result.
|
||||
func newTestBlackboard(t *testing.T) (*BlackboardTool, func(args string) string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
tool := &BlackboardTool{Root: root}
|
||||
run := func(args string) string {
|
||||
t.Helper()
|
||||
res, err := tool.Execute(context.Background(), "t1", json.RawMessage(args), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range res.Content {
|
||||
if txt, ok := c.(agentcore.TextContent); ok {
|
||||
sb.WriteString(txt.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
return tool, run
|
||||
}
|
||||
|
||||
func TestBlackboardPostAndRead(t *testing.T) {
|
||||
tool, run := newTestBlackboard(t)
|
||||
|
||||
got := run(`{"action":"post","file":"round-1-a.md","content":"hello from a"}`)
|
||||
if !strings.Contains(got, "appended") {
|
||||
t.Fatalf("post result = %q, want appended confirmation", got)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tool.Root, "messages", "round-1-a.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("message file not written: %v", err)
|
||||
}
|
||||
if string(data) != "hello from a" {
|
||||
t.Fatalf("message content = %q, want %q", data, "hello from a")
|
||||
}
|
||||
|
||||
// The snapshot lists the message; readFile returns its contents.
|
||||
snap := run(`{"action":"read"}`)
|
||||
if !strings.Contains(snap, "round-1-a.md") {
|
||||
t.Fatalf("snapshot missing the message name:\n%s", snap)
|
||||
}
|
||||
if !strings.Contains(snap, "DONE") || !strings.Contains(snap, "not created") {
|
||||
t.Fatalf("snapshot missing DONE status:\n%s", snap)
|
||||
}
|
||||
one := run(`{"action":"read","path":"messages/round-1-a.md"}`)
|
||||
if !strings.Contains(one, "hello from a") {
|
||||
t.Fatalf("readFile result missing content:\n%s", one)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardPostRejectsTraversal(t *testing.T) {
|
||||
tool, run := newTestBlackboard(t)
|
||||
for _, bad := range []string{
|
||||
`{"action":"post","file":"../escape.md","content":"x"}`,
|
||||
`{"action":"post","file":"a/b.md","content":"x"}`,
|
||||
`{"action":"post","file":"..","content":"x"}`,
|
||||
`{"action":"post","file":"notes.txt","content":"x"}`,
|
||||
`{"action":"post","file":"round.md","content":""}`,
|
||||
} {
|
||||
got := run(bad)
|
||||
if strings.Contains(got, "appended") {
|
||||
t.Fatalf("post with %s must be rejected, got %q", bad, got)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tool.Root, "escape.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("traversal escaped the root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardReadRejectsTraversal(t *testing.T) {
|
||||
_, run := newTestBlackboard(t)
|
||||
for _, bad := range []string{
|
||||
`{"action":"read","path":"../outside.md"}`,
|
||||
`{"action":"read","path":"/etc/passwd"}`,
|
||||
`{"action":"read","path":"messages"}`,
|
||||
} {
|
||||
got := run(bad)
|
||||
if !strings.Contains(got, "blackboard read:") {
|
||||
t.Fatalf("read with %s must error, got %q", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardDoneIsExclusive(t *testing.T) {
|
||||
tool, run := newTestBlackboard(t)
|
||||
|
||||
got := run(`{"action":"done","summary":"delivered: flag=abc"}`)
|
||||
if !strings.Contains(got, "DONE marker created") {
|
||||
t.Fatalf("first done failed: %q", got)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tool.Root, "DONE"))
|
||||
if err != nil {
|
||||
t.Fatalf("DONE not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "flag=abc") {
|
||||
t.Fatalf("DONE content missing summary: %q", data)
|
||||
}
|
||||
|
||||
// A second done must report the existing marker, not overwrite it.
|
||||
got2 := run(`{"action":"done","summary":"another summary"}`)
|
||||
if !strings.Contains(got2, "already exists") {
|
||||
t.Fatalf("second done must report existing marker, got %q", got2)
|
||||
}
|
||||
data2, _ := os.ReadFile(filepath.Join(tool.Root, "DONE"))
|
||||
if strings.Contains(string(data2), "another summary") {
|
||||
t.Fatalf("second done overwrote the marker: %q", data2)
|
||||
}
|
||||
|
||||
// done with an empty summary is rejected.
|
||||
if got := run(`{"action":"done","summary":""}`); !strings.Contains(got, "summary must not be empty") {
|
||||
t.Fatalf("empty-summary done must error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlackboardPostConcurrentAtomic verifies that parallel posts to the same
|
||||
// message file never interleave or lose bytes: each message survives whole.
|
||||
func TestBlackboardPostConcurrentAtomic(t *testing.T) {
|
||||
tool, _ := newTestBlackboard(t)
|
||||
const n = 16
|
||||
msgs := make([]string, n)
|
||||
for i := range msgs {
|
||||
msgs[i] = strings.Repeat("M", 100) + string(rune('A'+i)) + strings.Repeat("N", 100)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
args := json.RawMessage(`{"action":"post","file":"round-1-a.md","content":"` + msgs[i] + `"}`)
|
||||
if _, err := tool.Execute(context.Background(), "t", args, nil); err != nil {
|
||||
t.Errorf("concurrent post %d: %v", i, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tool.Root, "messages", "round-1-a.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read messages: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
for i, m := range msgs {
|
||||
if !strings.Contains(got, m) {
|
||||
t.Fatalf("message %d lost/interleaved in concurrent append:\n%s", i, got)
|
||||
}
|
||||
}
|
||||
// Every message must appear exactly once (no duplication from re-read+write).
|
||||
for _, m := range msgs {
|
||||
if strings.Count(got, m) != 1 {
|
||||
t.Fatalf("message %q appears %d times:\n%s", m, strings.Count(got, m), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardPostSizeCap(t *testing.T) {
|
||||
_, run := newTestBlackboard(t)
|
||||
huge := strings.Repeat("x", maxBlackboardMessageBytes+1)
|
||||
got := run(`{"action":"post","file":"big.md","content":"` + huge + `"}`)
|
||||
if !strings.Contains(got, "too large") {
|
||||
t.Fatalf("oversized post must error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardUnknownAction(t *testing.T) {
|
||||
_, run := newTestBlackboard(t)
|
||||
if got := run(`{"action":"bogus"}`); !strings.Contains(got, "unknown action") {
|
||||
t.Fatalf("unknown action must error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardNoRoot(t *testing.T) {
|
||||
tool := &BlackboardTool{}
|
||||
res, err := tool.Execute(context.Background(), "t", json.RawMessage(`{"action":"read"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
txt := res.Content[0].(agentcore.TextContent).Text
|
||||
if !strings.Contains(txt, "no blackboard root") {
|
||||
t.Fatalf("no-root read must error, got %q", txt)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user