From e2d78de7d66c4e337a871bc7147b67436125b0d1 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Mon, 16 Mar 2026 09:44:56 -0700 Subject: [PATCH] Improve Tests --- AGENTS.md | 102 ++++++++++++++++ .../whatsapp_native/whatsapp_command_test.go | 109 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..69ead96c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# AGENTS.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Build & Development Commands + +```bash +make build # Build for current platform (runs go generate first) +make build-all # Build for all supported platforms +make test # Run all tests (runs go generate first) +make lint # Run golangci-lint +make fmt # Format code via golangci-lint fmt +make vet # Run go vet static analysis +make check # Full pre-commit: deps + fmt + vet + test +make deps # Download and verify Go module dependencies +make generate # Run go generate (required before build/test) +``` + +**Running a single test:** +```bash +go test -run TestName -v ./pkg/session/ +``` + +**Build tags:** The default build uses `-tags stdjson`. WhatsApp native support requires `-tags whatsapp_native` and is limited to platforms where `modernc.org/sqlite` compiles (linux amd64/arm/arm64, darwin, windows amd64). + +**CGO is disabled** by default (`CGO_ENABLED=0`). + +## Architecture + +PicoClaw is a Go monorepo structured as a CLI application with two primary execution modes: + +- **Agent mode** (`picoclaw agent`): Interactive or one-shot LLM chat via the terminal. +- **Gateway mode** (`picoclaw gateway`): Long-running process that bridges LLM agents to messaging channels (Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Slack, IRC, etc.). + +### Core Data Flow + +Messages flow through an internal **MessageBus** (`pkg/bus`): + +``` +Channel (inbound) → MessageBus → AgentLoop → LLM Provider → Tool execution loop → MessageBus → Channel (outbound) +``` + +The `AgentLoop` (`pkg/agent/loop.go`) is the central orchestrator. It: +1. Consumes inbound messages from the bus +2. Resolves which `AgentInstance` handles the message via `AgentRegistry` + `RouteResolver` +3. Builds context (system prompt, session history, identity/memory/skills files from workspace) +4. Calls the LLM provider with tools +5. Runs a tool-call loop until the LLM produces a final text response or hits `max_tool_iterations` +6. Publishes the response back through the bus + +### Key Packages + +- **`pkg/agent`**: Agent loop, registry (multi-agent support), instance configuration, context builder. An `AgentInstance` encapsulates workspace, session store, tool registry, model config, and fallback candidates. +- **`pkg/bus`**: Channel-based message bus with typed inbound/outbound/media channels. Decouples channel adapters from the agent loop. +- **`pkg/channels`**: Channel adapters. Each channel implements the `Channel` interface (`Name`, `Start`, `Stop`, `Send`, `IsRunning`, `IsAllowed`). Optional interfaces: `TypingCapable`, `MessageEditor`, `ReactionCapable`, `PlaceholderCapable`. The `Manager` handles lifecycle, message splitting, placeholder editing, and outbound routing. +- **`pkg/providers`**: LLM provider abstraction. `LLMProvider` interface has `Chat()` and `GetDefaultModel()`. Implementations: `openai_compat` (covers most vendors), `anthropic`, `anthropic_messages`, `claude_cli`, `codex_cli`, `github_copilot`, `antigravity`. Includes `FallbackChain` for automatic failover with cooldown tracking. +- **`pkg/tools`**: Tool system. Each tool implements `Tool` interface (`Name`, `Description`, `Parameters`, `Execute`). `ToolRegistry` manages registration with core/hidden tool distinction and TTL-based promotion for MCP-discovered tools. Async tools implement `AsyncExecutor`. +- **`pkg/config`**: Configuration loading from `~/.picoclaw/config.json` with env var overrides (`PICOCLAW_CONFIG`, `PICOCLAW_HOME`). `model_list` is the modern provider config; legacy `providers` is still supported. +- **`pkg/routing`**: Multi-agent routing. `RouteResolver` matches inbound messages to agents based on channel/peer/account bindings. Supports a light-model router for cost optimization. +- **`pkg/session`**: Conversation history storage (file-based per session key). Supports summarization when context grows. +- **`pkg/skills`**: Skill loading from workspace, global, and builtin directories. +- **`pkg/mcp`**: Model Context Protocol integration for dynamic tool discovery. +- **`pkg/commands`**: Unified slash-command registry and executor. Channel adapters forward commands to the central executor rather than handling them locally. +- **`pkg/heartbeat`**: Periodic task execution from `HEARTBEAT.md`. +- **`pkg/memory`**: Long-term memory via `MEMORY.md` in workspace. + +### CLI Structure + +Entry point: `cmd/picoclaw/main.go` using `cobra`. Subcommands in `cmd/picoclaw/internal/` (agent, gateway, onboard, auth, cron, migrate, model, skills, status, version). + +### Web Launcher + +`web/frontend` (pnpm-based) + `web/backend` (Go). Built via `make build-launcher`. Provides a browser UI at port 18800 that manages the gateway process. + +### Provider Model + +Providers are resolved from `model_list` config entries using `vendor/model` format (e.g. `openai/gpt-5.4`). The vendor prefix determines the protocol/API base. `FallbackChain` tries candidates in order with cooldown on failures. `FailoverError` classifies failure reasons to decide retriability. + +### Workspace Files + +The agent reads several markdown files from its workspace directory at context-build time: +- `AGENTS.md` — agent behavior instructions +- `IDENTITY.md` — agent persona +- `SOUL.md` — agent personality/values +- `USER.md` — user preferences +- `MEMORY.md` — long-term memory +- `HEARTBEAT.md` — periodic task definitions + +### Security Sandbox + +When `restrict_to_workspace: true` (default), file and exec tools are confined to the workspace directory. Dangerous command patterns (rm -rf, format, dd, shutdown, fork bomb) are blocked even when restrictions are disabled. + +## Code Conventions + +- Go 1.25+, module path `github.com/sipeed/picoclaw` +- Linting: `golangci-lint` with most linters enabled (see `.golangci.yaml` for disabled list) +- Formatters: `gofmt`, `gofumpt`, `goimports`, `golines` (max 120 chars), `gci` (import ordering: standard → third-party → local module) +- Test framework: standard `testing` + `github.com/stretchr/testify` +- Logging: `pkg/logger` wrapping `zerolog`. Use structured logging: `logger.InfoCF("component", "message", map[string]any{...})` +- All CI checks must pass (`make check`) before merge +- Branch off `main`, squash merge, conventional commits +- AI-assisted contributions require disclosure in PR template diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go index cc2dcb619..15733e370 100644 --- a/pkg/channels/whatsapp_native/whatsapp_command_test.go +++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go @@ -54,3 +54,112 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) { t.Fatalf("content=%q", inbound.Content) } } + +func TestHandleIncoming_AllowFromFiltering(t *testing.T) { + t.Run("blocked sender is discarded", func(t *testing.T) { + messageBus := bus.NewMessageBus() + cfg := config.WhatsAppConfig{AllowFrom: []string{"9999@s.whatsapp.net"}} + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", cfg, messageBus, cfg.AllowFrom), + runCtx: context.Background(), + } + + evt := &events.Message{ + Info: types.MessageInfo{ + MessageSource: types.MessageSource{ + Sender: types.NewJID("1001", types.DefaultUserServer), + Chat: types.NewJID("1001", types.DefaultUserServer), + }, + ID: "mid1", + PushName: "BlockedUser", + }, + Message: &waE2E.Message{ + Conversation: proto.String("hello"), + }, + } + + ch.handleIncoming(evt) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + _, ok := messageBus.ConsumeInbound(ctx) + if ok { + t.Fatal("expected message from non-allowed sender to be discarded") + } + }) + + t.Run("allowed sender is delivered", func(t *testing.T) { + messageBus := bus.NewMessageBus() + cfg := config.WhatsAppConfig{AllowFrom: []string{"9999@s.whatsapp.net"}} + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", cfg, messageBus, cfg.AllowFrom), + runCtx: context.Background(), + } + + evt := &events.Message{ + Info: types.MessageInfo{ + MessageSource: types.MessageSource{ + Sender: types.NewJID("9999", types.DefaultUserServer), + Chat: types.NewJID("9999", types.DefaultUserServer), + }, + ID: "mid2", + PushName: "AllowedUser", + }, + Message: &waE2E.Message{ + Conversation: proto.String("allowed message"), + }, + } + + ch.handleIncoming(evt) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message from allowed sender to be forwarded") + } + if inbound.Channel != "whatsapp_native" { + t.Fatalf("channel=%q", inbound.Channel) + } + if inbound.Content != "allowed message" { + t.Fatalf("content=%q", inbound.Content) + } + }) + + t.Run("empty allowlist allows all", func(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil), + runCtx: context.Background(), + } + + evt := &events.Message{ + Info: types.MessageInfo{ + MessageSource: types.MessageSource{ + Sender: types.NewJID("1001", types.DefaultUserServer), + Chat: types.NewJID("1001", types.DefaultUserServer), + }, + ID: "mid3", + PushName: "AnyUser", + }, + Message: &waE2E.Message{ + Conversation: proto.String("any user message"), + }, + } + + ch.handleIncoming(evt) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected inbound message to be forwarded when allowlist is empty") + } + if inbound.Content != "any user message" { + t.Fatalf("content=%q", inbound.Content) + } + }) +}