Files
Algis DumbrisandClaude Opus 4.6 cda3365863 feat(016): agent marketplace MVP — capability manifests, auction channels, reputation ledger
Implements US1, US2, and US3 of spec 016 by layering a marketplace service
on top of existing primitives rather than reinventing them:

- Capability manifests (US2) reuse the wiki subsystem. Each agent publishes
  a per-agent article at slug "agent-<name>" and gets versioning, revision
  history, and FTS search for free.
- Auction channels (US1) reuse the existing auction channel type, swarm
  service, and task/bid store. post_auction / bid / award wrap post_task /
  bid_task / accept_bid and attach marketplace metadata (max_budget_tokens,
  domains, estimated_tokens, confidence, approach) in the task.requirements
  and bid.capabilities JSON blobs. Award converts the auction into a claim
  by DM'ing the winner at priority 8 with task_id metadata, so the existing
  claim/process/done lifecycle takes over with zero new machinery.
- Reputation ledger (US3) adds migration 018_agent_marketplace.sql with a
  new agent_reputation table keyed by (agent_name, domain). mark_task_done
  completes the task via the swarm service and writes one ledger row per
  declared domain using the reported actual_tokens and success_score.
  query_reputation returns a rolled-up summary plus recent entries for a
  given (agent, domain) pair — reputation is always a vector, never a
  global score (FR-013).

Also:
- Adds the "awarded" reaction type (FR-008) alongside existing approve/
  reject/in_progress/done/published. Migration 018 widens the reactions
  CHECK constraint via a table rebuild.
- 6 new actions added to the action registry (post_auction, bid, award,
  mark_task_done, read_skill_card, query_reputation) so the search tool
  can discover them and the execute tool can dispatch them.
- New internal/marketplace package (store.go + service.go).
- New internal/mcp/marketplace.go bridge handlers.
- New internal/mcp/marketplace_test.go covers the full auction lifecycle,
  capability manifest publish/read/update, self-bid rejection, non-auction
  channel rejection, and reputation summary aggregation.

Out of scope for MVP (deferred per spec prompt): US4 reflection loop,
tombstoning FR-020a/b, multi-owner quorums, auto-escalation on zero bids,
bootstrap exploration credit, epsilon-greedy selection, and the hard-stop
budget enforcement daemon (only soft recording of estimated vs actual is
included).

All existing tests pass; new marketplace tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:17:30 +03:00

147 lines
3.4 KiB
Go

package actions
import (
"testing"
)
func TestRegistryHasAllActions(t *testing.T) {
r := NewRegistry()
got := len(r.List())
const want = 41 // 35 original + 6 marketplace (spec 016)
if got != want {
t.Errorf("expected %d actions, got %d", want, got)
}
}
func TestRegistryCategories(t *testing.T) {
r := NewRegistry()
tests := []struct {
category string
want int
}{
{"messaging", 7},
{"channels", 9},
{"swarm", 5},
{"attachments", 2},
{"reactions", 4},
{"threads", 1},
{"trust", 1},
{"data", 1},
{"wiki", 5},
{"marketplace", 6},
}
for _, tt := range tests {
t.Run(tt.category, func(t *testing.T) {
got := len(r.ListByCategory(tt.category))
if got != tt.want {
t.Errorf("category %q: expected %d actions, got %d", tt.category, tt.want, got)
}
})
}
}
func TestRegistryGetByName(t *testing.T) {
r := NewRegistry()
allNames := []string{
// messaging
"my_status", "send_message", "read_inbox", "claim_messages", "mark_done", "search_messages", "discover_agents",
// channels
"create_channel", "join_channel", "leave_channel", "list_channels",
"invite_to_channel", "kick_from_channel", "get_channel_messages",
"send_channel_message", "update_channel",
// swarm
"post_task", "bid_task", "accept_bid", "complete_task", "list_tasks",
// attachments
"upload_attachment", "download_attachment",
// reactions
"react", "unreact", "get_reactions", "list_by_state",
// threads
"get_replies",
// trust
"get_trust",
// data
"query",
// wiki
"create_article", "get_article", "update_article", "list_articles", "get_backlinks",
// marketplace (spec 016)
"post_auction", "bid", "award", "mark_task_done", "read_skill_card", "query_reputation",
}
for _, name := range allNames {
t.Run(name, func(t *testing.T) {
a, ok := r.Get(name)
if !ok {
t.Fatalf("action %q not found in registry", name)
}
if a.Name != name {
t.Errorf("expected name %q, got %q", name, a.Name)
}
})
}
}
func TestRegistryGetNotFound(t *testing.T) {
r := NewRegistry()
_, ok := r.Get("nonexistent_action")
if ok {
t.Error("expected Get to return false for nonexistent action")
}
}
func TestRegistryActionsHaveExamples(t *testing.T) {
r := NewRegistry()
for _, a := range r.List() {
t.Run(a.Name, func(t *testing.T) {
if len(a.Examples) == 0 {
t.Errorf("action %q has no examples", a.Name)
}
})
}
}
func TestRegistryActionsHaveDescriptions(t *testing.T) {
r := NewRegistry()
for _, a := range r.List() {
t.Run(a.Name, func(t *testing.T) {
if a.Description == "" {
t.Errorf("action %q has empty description", a.Name)
}
})
}
}
func TestRegistryActionsHaveReturns(t *testing.T) {
r := NewRegistry()
for _, a := range r.List() {
t.Run(a.Name, func(t *testing.T) {
if a.Returns == "" {
t.Errorf("action %q has empty Returns field", a.Name)
}
})
}
}
func TestRegistryListByUnknownCategory(t *testing.T) {
r := NewRegistry()
got := r.ListByCategory("nonexistent")
if len(got) != 0 {
t.Errorf("expected 0 actions for unknown category, got %d", len(got))
}
}
func TestRegistryListReturnsCopy(t *testing.T) {
r := NewRegistry()
list1 := r.List()
list2 := r.List()
// Mutating the first list should not affect the second.
if len(list1) > 0 {
list1[0].Name = "mutated"
if list2[0].Name == "mutated" {
t.Error("List() should return a copy, not a reference to internal slice")
}
}
}