mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 10:43:36 +00:00
728c42e47b
Closes the last known gap in the agent substrate. The three
action endpoints (POST /api/actions/plan, /api/actions/{id}/decision,
/api/actions/{id}/execute) previously emitted the platform-wide
APIError shape (stable code under "code", human under "error").
The agent surface uses the inverted shape (stable code under
"error", human under "message"), so adding action capabilities
to the manifest as-is would have forced agents to remember which
envelope each capability uses.
The slice refactors actions.go to emit the agent-stable envelope
across all 42 writeErrorResponse call sites. writeJSONError gains
a writeJSONErrorWithDetails sibling so the 13 calls that pass
field-level reasons (validation failures) preserve that
information under a new optional `details` field. The action
endpoints' JSON shape becomes:
{"error": "<stable_code>", "message": "<human>",
"details"?: {"<field>": "<reason>"}}
Frontend impact: zero. Verified that no frontend code consumes
the three action endpoints; the refactor is API-only.
Three new manifest entries (plan_action, decide_action,
execute_action) under a new "action" category, with their
declared error codes pinned per capability. Internal-failure 5xx
codes (audit-store outages, encode failures) are not declared
per capability; agents branch on 5xx generically.
TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations now
audits actions.go alongside the existing two handler files, with
a documented internal-only allowlist for the 5xx codes.
The TestAgentSubstrate_ActionEndpointsEmitAgentStableEnvelope e2e
test exercises one error path through each endpoint via the actual
HTTP boundary, asserting the agent-stable envelope reaches the
wire and the legacy APIError fields (code, status_code, timestamp)
do NOT — drift back would mean the refactor regressed.
The TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot pin
is updated to match the new envelope shape; the manifest's
category allowlist gains "action".
api-contracts.md documents the new envelope (with details map),
the action governance loop's place in the substrate, the
ai:execute scope distinction from monitoring:write, and the
"manifest projection has a footnote" trade-off: bringing an
existing endpoint into the agent surface may require migrating
its error envelope, but the substrate keeps a single envelope
contract rather than carrying a translation wrapper layer.
agent-lifecycle.md and storage-recovery.md document the action
surface joining the agent paradigm and its zero-new-persistence
posture respectively. AGENT_SUBSTRATE.md's "what it does not do
yet" no longer lists the action surface; it now reflects the
real outstanding items (consumer feedback, an in-Pulse agent
integrations panel, a distribution path for pulse-mcp).
148 lines
4.7 KiB
Go
148 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,
|
|
"action": 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)
|
|
}
|
|
}
|
|
}
|
|
}
|