Files
pulse/internal/api/agent_capabilities_test.go
T
rcourtman 71797f9b21 Add /api/agent/capabilities discovery manifest for agent integrations
Second slice on the agent-paradigm pivot: the discovery document any
external agent (Claude Code, custom integrations, future MCP servers)
needs to learn what Pulse exposes. Each capability declares its
agent-stable name (snake_case), description, category, REST surface,
required scope, response shape, and the closed set of stable error
codes the response may carry. Agents branch on the codes
(operator_state_invalid, resource_not_found, etc.) rather than
parsing human messages.

The manifest is hand-authored, not auto-generated, because the
contract decisions (what's agent-stable, which categories, which
error codes) are product-shaping and must not drift behind code
changes. Adding a capability is a deliberate "this is part of the
agent surface" commitment.

v1 surface includes: get_resource_context (substrate from slice 39),
get/set/clear_operator_state (slice 30), and the finding-lifecycle
actions (acknowledge, snooze, dismiss, resolve). Action-broker
capabilities are not in v1 because they go through approval flow,
not direct dispatch — those need their own contract design.

Tests pin: stable shape, version contract, unique-and-snake_case
names, every capability has method/path/scope, closed category set,
required error codes for the most consequential capabilities. The
manifest is unauthenticated and cacheable (5min); the underlying
capabilities keep their own auth scopes.
2026-05-09 19:52:17 +01:00

147 lines
4.7 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandleAgentCapabilitiesManifest_ReturnsStableShape(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/capabilities", nil)
HandleAgentCapabilitiesManifest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200; got %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type: got %q want application/json", got)
}
// Cacheable so agents can hold the manifest in memory across
// requests; 5 minutes mirrors the typical agent session length.
if got := rec.Header().Get("Cache-Control"); got == "" {
t.Error("manifest must be cacheable; Cache-Control header missing")
}
var manifest AgentCapabilitiesManifest
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
if manifest.Version != "v1" {
t.Errorf("version pin: got %q want v1", manifest.Version)
}
if len(manifest.Capabilities) == 0 {
t.Fatal("manifest must declare at least one capability")
}
}
func TestHandleAgentCapabilitiesManifest_RejectsNonGet(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/agent/capabilities", nil)
HandleAgentCapabilitiesManifest(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405; got %d", rec.Code)
}
}
func TestAgentCapabilitiesManifest_NamesAreUniqueAndSnakeCase(t *testing.T) {
// Capability names are agent-stable identifiers — duplicates
// would silently mask one capability behind another, and
// non-snake_case would break the convention agents use for tool
// names. Pin both invariants.
seen := map[string]bool{}
for _, cap := range agentCapabilitiesManifest.Capabilities {
if seen[cap.Name] {
t.Errorf("duplicate capability name %q — names are agent-stable identifiers", cap.Name)
}
seen[cap.Name] = true
if cap.Name == "" {
t.Error("capability name must not be empty")
continue
}
for _, ch := range cap.Name {
if !(ch == '_' || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
t.Errorf("capability name %q must be snake_case (lowercase letters, digits, underscores only); got rune %q", cap.Name, ch)
break
}
}
}
}
func TestAgentCapabilitiesManifest_EveryCapabilityDeclaresMethodPathScope(t *testing.T) {
// Without method/path/scope, an agent can't actually call the
// capability. These three are the minimum viable contract.
for _, cap := range agentCapabilitiesManifest.Capabilities {
if cap.Method == "" {
t.Errorf("capability %q missing method", cap.Name)
}
if cap.Path == "" {
t.Errorf("capability %q missing path", cap.Name)
}
if cap.Scope == "" {
t.Errorf("capability %q missing scope", cap.Name)
}
if cap.Description == "" {
t.Errorf("capability %q missing description", cap.Name)
}
if cap.Category == "" {
t.Errorf("capability %q missing category — agents filter the manifest by category", cap.Name)
}
}
}
func TestAgentCapabilitiesManifest_CategoriesAreClosed(t *testing.T) {
// Agents filter the manifest by category. Keep the set closed
// so a typo in a future capability doesn't fragment the surface
// (e.g. "operator-state" vs "operator_state" would split into
// two categories an agent might miss).
allowed := map[string]bool{
"context": true,
"operator-state": true,
"finding": true,
}
for _, cap := range agentCapabilitiesManifest.Capabilities {
if !allowed[cap.Category] {
t.Errorf("capability %q has unknown category %q — extend the allowlist deliberately", cap.Name, cap.Category)
}
}
}
func TestAgentCapabilitiesManifest_CarriesStableErrorCodes(t *testing.T) {
// The error-code surface is the agent-branching contract. The
// codes I've shipped this session must appear on the
// corresponding capability so agents can branch on them. Pin a
// few of the most consequential codes.
wantErrorCodes := map[string][]string{
"get_resource_context": {"resource_not_found"},
"get_operator_state": {"operator_state_not_set"},
"set_operator_state": {"operator_state_invalid"},
}
byName := map[string]AgentCapability{}
for _, cap := range agentCapabilitiesManifest.Capabilities {
byName[cap.Name] = cap
}
for name, expected := range wantErrorCodes {
cap, ok := byName[name]
if !ok {
t.Errorf("capability %q missing from manifest", name)
continue
}
for _, code := range expected {
found := false
for _, declared := range cap.ErrorCodes {
if declared == code {
found = true
break
}
}
if !found {
t.Errorf("capability %q must declare error code %q so agents can branch on it; declared codes: %v", name, code, cap.ErrorCodes)
}
}
}
}