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) } }