Pulse/pkg/aicontracts/contracts_test.go
2026-08-15 12:38:41 +01:00

444 lines
18 KiB
Go

package aicontracts
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
)
func TestFindingObjectiveContextIsAdditiveBoundedIntentOnly(t *testing.T) {
finding := Finding{
ID: "finding-1",
ObjectiveContext: &PatrolObjectiveContext{
ObjectiveID: "objective-1",
Revision: 3,
Brief: "Keep cameras available",
Context: "Avoid recording gaps",
ObserverID: "observer-1",
ObserverVersion: 2,
ObservedResourceIDs: []string{"camera-1"},
Evidence: "local status predicate breached",
ObservedAt: time.Date(2026, 8, 14, 2, 0, 0, 0, time.UTC),
},
}
payload, err := json.Marshal(finding)
if err != nil {
t.Fatalf("marshal finding objective context: %v", err)
}
text := string(payload)
for _, want := range []string{`"objective_context":{`, `"objective_id":"objective-1"`, `"revision":3`, `"brief":"Keep cameras available"`, `"observer_id":"observer-1"`, `"observed_resource_ids":["camera-1"]`, `"evidence":"local status predicate breached"`} {
if !strings.Contains(text, want) {
t.Fatalf("objective context wire shape missing %s: %s", want, text)
}
}
for _, forbidden := range []string{`"command"`, `"capability_name"`, `"params"`, `"approval"`, `"autonomy"`, `"plan_hash"`} {
if strings.Contains(text, forbidden) {
t.Fatalf("objective context must not carry authority field %s: %s", forbidden, text)
}
}
empty, err := json.Marshal(Finding{ID: "legacy-finding"})
if err != nil {
t.Fatalf("marshal legacy finding: %v", err)
}
if strings.Contains(string(empty), `"objective_context"`) {
t.Fatalf("absent objective context must remain omitted for legacy findings: %s", empty)
}
}
func TestDefaultInvestigationConfigReservesCompletionBeyondTenEvidenceCalls(t *testing.T) {
cfg := DefaultInvestigationConfig()
if cfg.MaxEvidenceCalls != 10 {
t.Fatalf("MaxEvidenceCalls = %d, want 10", cfg.MaxEvidenceCalls)
}
if cfg.MaxTurns != 12 {
t.Fatalf("MaxTurns = %d, want ten evidence calls plus two completion turns", cfg.MaxTurns)
}
if got := InvestigationModelTurnLimit(0); got != 12 {
t.Fatalf("zero-budget model turn limit = %d, want 12", got)
}
}
func TestEmptyOrchestratorMessage_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyOrchestratorMessage())
if err != nil {
t.Fatalf("marshal empty orchestrator message: %v", err)
}
if !strings.Contains(string(payload), `"tool_calls":[]`) {
t.Fatalf("expected empty orchestrator message to retain tool_calls array, got %s", payload)
}
}
func TestEmptyOrchestratorToolCallInfo_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyOrchestratorToolCallInfo())
if err != nil {
t.Fatalf("marshal empty orchestrator tool call info: %v", err)
}
if !strings.Contains(string(payload), `"input":{}`) {
t.Fatalf("expected empty orchestrator tool call info to retain input object, got %s", payload)
}
payload, err = json.Marshal(OrchestratorMessage{
ToolCalls: []OrchestratorToolCallInfo{{
ID: "call-1",
Name: "diagnose",
}},
}.NormalizeCollections())
if err != nil {
t.Fatalf("marshal normalized orchestrator message with tool call: %v", err)
}
if !strings.Contains(string(payload), `"input":{}`) {
t.Fatalf("expected normalized orchestrator tool call to retain input object, got %s", payload)
}
}
func TestOrchestratorToolCallInfoUsesSharedProviderProjection(t *testing.T) {
input := map[string]interface{}{
"resource_id": "vm/100",
"body": map[string]interface{}{
"tags": []interface{}{"prod"},
},
}
signature := json.RawMessage(`{"provider":"gemini"}`)
info := OrchestratorToolCallInfoFromProvider(agentcapabilities.ProviderToolCall{
ID: "call-1",
Name: "diagnose",
Input: input,
ThoughtSignature: signature,
})
if info.ID != "call-1" || info.Name != "diagnose" || info.Input == nil {
t.Fatalf("orchestrator tool call info = %+v", info)
}
info.Input["resource_id"] = "vm/101"
info.Input["body"].(map[string]interface{})["tags"].([]interface{})[0] = "changed"
info.ThoughtSignature[0] = '['
if input["resource_id"] != "vm/100" || input["body"].(map[string]interface{})["tags"].([]interface{})[0] != "prod" {
t.Fatalf("orchestrator projection must not alias provider input: source=%#v info=%#v", input, info.Input)
}
if string(signature) != `{"provider":"gemini"}` {
t.Fatalf("orchestrator projection must not alias provider thought signature: source=%s info=%s", signature, info.ThoughtSignature)
}
orchestratorInput := map[string]interface{}{"resource_id": "vm/300"}
infoForProvider := OrchestratorToolCallInfo{
ID: "call-2",
Name: "inspect",
Input: orchestratorInput,
ThoughtSignature: json.RawMessage(`{"provider":"gemini"}`),
}.NormalizeCollections()
provider := infoForProvider.ProviderToolCall()
provider.Input["resource_id"] = "vm/102"
provider.ThoughtSignature[0] = '['
if infoForProvider.Input["resource_id"] != "vm/300" || string(infoForProvider.ThoughtSignature) != `{"provider":"gemini"}` {
t.Fatalf("provider projection must not alias orchestrator tool call info: info=%#v provider=%#v", infoForProvider, provider)
}
}
func TestOrchestratorToolResultInfoUsesSharedProviderProjection(t *testing.T) {
info := OrchestratorToolResultInfoFromProvider(agentcapabilities.NewProviderToolResult("call-1", "done", true))
if info.ToolUseID != "call-1" || info.Content != "done" || !info.IsError {
t.Fatalf("orchestrator tool result info = %+v", info)
}
var shared agentcapabilities.ProviderToolResult = info.ProviderToolResult()
if shared.ToolUseID != "call-1" || shared.Content != "done" || !shared.IsError {
t.Fatalf("shared provider tool result = %+v", shared)
}
}
func TestEmptyInvestigationSession_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyInvestigationSession())
if err != nil {
t.Fatalf("marshal empty investigation session: %v", err)
}
if !strings.Contains(string(payload), `"tools_available":[]`) {
t.Fatalf("expected empty investigation session to retain tools_available array, got %s", payload)
}
if !strings.Contains(string(payload), `"tools_used":[]`) {
t.Fatalf("expected empty investigation session to retain tools_used array, got %s", payload)
}
if !strings.Contains(string(payload), `"evidence_ids":[]`) {
t.Fatalf("expected empty investigation session to retain evidence_ids array, got %s", payload)
}
}
func TestEmptyInvestigationRecord_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyInvestigationRecord())
if err != nil {
t.Fatalf("marshal empty investigation record: %v", err)
}
if !strings.Contains(string(payload), `"evidence":[]`) {
t.Fatalf("expected empty investigation record to retain evidence array, got %s", payload)
}
if !strings.Contains(string(payload), `"verification":[]`) {
t.Fatalf("expected empty investigation record to retain verification array, got %s", payload)
}
if !strings.Contains(string(payload), `"tools_used":[]`) {
t.Fatalf("expected empty investigation record to retain tools_used array, got %s", payload)
}
}
func TestInvestigationOutcomeFixRejected_IsCanonicalWireValue(t *testing.T) {
payload, err := json.Marshal(struct {
Outcome InvestigationOutcome `json:"outcome"`
}{
Outcome: OutcomeFixRejected,
})
if err != nil {
t.Fatalf("marshal rejected investigation outcome: %v", err)
}
if !strings.Contains(string(payload), `"outcome":"fix_rejected"`) {
t.Fatalf("expected rejected investigation outcome wire value, got %s", payload)
}
}
func TestEmptyFix_UsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(EmptyFix())
if err != nil {
t.Fatalf("marshal empty fix: %v", err)
}
if !strings.Contains(string(payload), `"commands":[]`) {
t.Fatalf("expected empty fix to retain commands array, got %s", payload)
}
}
func TestActionProposalWireShapeIsTypedAndCommandFree(t *testing.T) {
payload, err := json.Marshal(ActionProposal{
ProposalID: "prop-1",
FindingID: "finding-1",
InvestigationID: "inv-1",
ResourceID: "vm:42",
CapabilityName: "restart",
Params: map[string]any{"mode": "graceful"},
Reason: "recover",
})
if err != nil {
t.Fatalf("marshal action proposal: %v", err)
}
for _, want := range []string{`"proposal_id":"prop-1"`, `"resource_id":"vm:42"`, `"capability_name":"restart"`} {
if !strings.Contains(string(payload), want) {
t.Fatalf("proposal wire shape missing %s: %s", want, payload)
}
}
// The proposal contract must stay command-free: no command, target
// host, risk, destructive, or approval fields exist for enterprise
// code to smuggle authority through.
for _, forbidden := range []string{"command", "target_host", "risk", "destructive", "approval", "autonomy"} {
if strings.Contains(string(payload), forbidden) {
t.Fatalf("proposal wire shape must not carry %q: %s", forbidden, payload)
}
}
}
func TestActionReferenceIsAdditiveOnInvestigationShapes(t *testing.T) {
session := EmptyInvestigationSession()
session.Action = &ActionReference{
ActionID: "act-1",
ResourceID: "vm:42",
CapabilityName: "restart",
State: "pending_approval",
}
payload, err := json.Marshal(session)
if err != nil {
t.Fatalf("marshal investigation session with action reference: %v", err)
}
if !strings.Contains(string(payload), `"action":{`) || !strings.Contains(string(payload), `"action_id":"act-1"`) {
t.Fatalf("investigation session must carry the action reference, got %s", payload)
}
record := EmptyInvestigationRecord()
record.Action = &ActionReference{ActionID: "act-1", ResourceID: "vm:42", CapabilityName: "restart", State: "planned"}
recordPayload, err := json.Marshal(record)
if err != nil {
t.Fatalf("marshal investigation record with action reference: %v", err)
}
if !strings.Contains(string(recordPayload), `"action_id":"act-1"`) {
t.Fatalf("investigation record must carry the action reference, got %s", recordPayload)
}
// Absent references stay absent: legacy readers must not see a new
// non-optional field.
emptyPayload, err := json.Marshal(EmptyInvestigationSession())
if err != nil {
t.Fatalf("marshal empty investigation session: %v", err)
}
if strings.Contains(string(emptyPayload), `"action"`) {
t.Fatalf("empty investigation session must omit the action reference, got %s", emptyPayload)
}
}
func TestOrchestratorInvestigationErrorPreservesBothFailureChannels(t *testing.T) {
runFailure := errors.New("provider unavailable")
err := NewOrchestratorInvestigationError(runFailure, ErrInvestigationProposalAmbiguous)
if !errors.Is(err, runFailure) {
t.Fatal("joined investigation error lost the runtime failure")
}
if !errors.Is(err, ErrInvestigationProposalAmbiguous) {
t.Fatal("joined investigation error lost the proposal failure")
}
var investigationErr *OrchestratorInvestigationError
if !errors.As(err, &investigationErr) {
t.Fatalf("error type = %T, want *OrchestratorInvestigationError", err)
}
if investigationErr.RunFailure() != runFailure {
t.Fatalf("RunFailure() = %v, want %v", investigationErr.RunFailure(), runFailure)
}
if investigationErr.ProposalFailure() != ErrInvestigationProposalAmbiguous {
t.Fatalf("ProposalFailure() = %v", investigationErr.ProposalFailure())
}
}
func TestOrchestratorActionBrokerIsProposeOnly(t *testing.T) {
// The broker seam must never grow decision or execution authority:
// enterprise investigation code proposes, the canonical core
// lifecycle decides and executes. Guard the method set so a Decide
// or Execute method cannot be added without failing this proof.
brokerType := reflect.TypeOf((*OrchestratorActionBroker)(nil)).Elem()
if brokerType.NumMethod() != 2 {
t.Fatalf("OrchestratorActionBroker must stay propose-only (Capabilities, Submit), got %d methods", brokerType.NumMethod())
}
for _, name := range []string{"Capabilities", "Submit"} {
if _, ok := brokerType.MethodByName(name); !ok {
t.Fatalf("OrchestratorActionBroker missing method %s", name)
}
}
for _, forbidden := range []string{"Decide", "Execute", "Approve", "ExecuteCommand"} {
if _, ok := brokerType.MethodByName(forbidden); ok {
t.Fatalf("OrchestratorActionBroker must not expose %s", forbidden)
}
}
}
func TestActionBrokerPolicyMetadataCarriesNoAuthorizationMethod(t *testing.T) {
catalogPayload, err := json.Marshal(ActionCapabilityCatalog{
ResourceID: "app-container:demo",
Capabilities: []ActionCapabilityInfo{{
Name: "restart",
AutoAuthorization: "low_risk",
}},
})
if err != nil {
t.Fatalf("marshal capability catalog: %v", err)
}
if !strings.Contains(string(catalogPayload), `"auto_authorization":"low_risk"`) {
t.Fatalf("catalog must expose core-owned eligibility: %s", catalogPayload)
}
dispositionPayload, err := json.Marshal(ActionDisposition{
ActionID: "act-1",
State: "completed",
ActionResultV2: json.RawMessage(`{"version":2,"execution":{"status":"succeeded"},"verification":{"status":"confirmed","evidenceClass":"independent"},"compensation":{"support":"unavailable","status":"not_available"}}`),
VerificationStatus: "verified",
})
if err != nil {
t.Fatalf("marshal action disposition: %v", err)
}
if !strings.Contains(string(dispositionPayload), `"verification_status":"verified"`) {
t.Fatalf("disposition must carry terminal verification: %s", dispositionPayload)
}
if !strings.Contains(string(dispositionPayload), `"action_result_v2":{"version":2`) {
t.Fatalf("disposition must carry canonical two-axis result: %s", dispositionPayload)
}
for _, forbidden := range []string{"authorize", "approve", "execute"} {
if strings.Contains(string(catalogPayload), forbidden) || strings.Contains(string(dispositionPayload), forbidden) {
t.Fatalf("broker wire metadata must not carry a %s authority field", forbidden)
}
}
}
func TestOrchestratorDepsExposesTypedActionBroker(t *testing.T) {
depsType := reflect.TypeOf(OrchestratorDeps{})
field, ok := depsType.FieldByName("ActionBroker")
if !ok {
t.Fatal("OrchestratorDeps must expose the typed ActionBroker seam")
}
if field.Type != reflect.TypeOf((*OrchestratorActionBroker)(nil)).Elem() {
t.Fatalf("ActionBroker field type = %v, want OrchestratorActionBroker", field.Type)
}
}
func TestOrchestratorChatServiceIsInvestigationOnly(t *testing.T) {
svcType := reflect.TypeOf((*OrchestratorChatService)(nil)).Elem()
// The generic-execution and autonomy surface is gone: no method may
// carry autonomy, generic execution, or a broad tool listing.
for _, forbidden := range []string{"ExecuteStream", "SetAutonomousMode", "ListAvailableTools"} {
if _, ok := svcType.MethodByName(forbidden); ok {
t.Fatalf("OrchestratorChatService must not expose %s", forbidden)
}
}
for _, required := range []string{"ExecuteInvestigationStream", "ListInvestigationTools"} {
if _, ok := svcType.MethodByName(required); !ok {
t.Fatalf("OrchestratorChatService must expose %s", required)
}
}
// The investigation request must carry no autonomy field.
reqType := reflect.TypeOf(OrchestratorInvestigationRequest{})
for i := 0; i < reqType.NumField(); i++ {
name := strings.ToLower(reqType.Field(i).Name)
if strings.Contains(name, "autonom") {
t.Fatalf("investigation request must carry no autonomy field, found %s", reqType.Field(i).Name)
}
}
}
func TestOrchestratorDepsHasNoCommandOrAutonomyDeps(t *testing.T) {
depsType := reflect.TypeOf(OrchestratorDeps{})
for _, forbidden := range []string{"CmdExecutor", "ApprovalStore", "Autonomy", "FixVerifier", "License"} {
if _, ok := depsType.FieldByName(forbidden); ok {
t.Fatalf("OrchestratorDeps must not expose %s after the typed-lifecycle migration", forbidden)
}
}
}
func TestActionCapabilityParamInfoCarriesPattern(t *testing.T) {
// Canonical planner validation enforces Pattern; the cross-repo
// capability projection must carry it or proposal validation parity
// dies at the boundary.
if _, ok := reflect.TypeOf(ActionCapabilityParamInfo{}).FieldByName("Pattern"); !ok {
t.Fatal("ActionCapabilityParamInfo must carry Pattern")
}
}
func TestInvestigationResultCarriesStructuredProposal(t *testing.T) {
resultType := reflect.TypeOf(OrchestratorInvestigationResult{})
field, ok := resultType.FieldByName("Proposal")
if !ok {
t.Fatal("investigation result must carry a structured Proposal")
}
if field.Type != reflect.TypeOf((*ActionProposal)(nil)) {
t.Fatalf("Proposal field type = %v, want *ActionProposal", field.Type)
}
}
func TestInvestigationBudgetSeparatesEvidenceCallsFromModelTurns(t *testing.T) {
requestType := reflect.TypeOf(OrchestratorInvestigationRequest{})
for _, name := range []string{"MaxTurns", "MaxEvidenceCalls", "ResourceType"} {
if _, ok := requestType.FieldByName(name); !ok {
t.Fatalf("investigation request missing %s", name)
}
}
resultType := reflect.TypeOf(OrchestratorInvestigationResult{})
for _, name := range []string{"ModelTurns", "EvidenceCalls", "ToolCalls"} {
if _, ok := resultType.FieldByName(name); !ok {
t.Fatalf("investigation result missing %s", name)
}
}
if got := InvestigationModelTurnLimit(15); got != 17 {
t.Fatalf("InvestigationModelTurnLimit(15) = %d, want 17", got)
}
sessionPayload, err := json.Marshal(InvestigationSession{TurnCount: 3, EvidenceCallCount: 7, ToolCallCount: 8}.NormalizeCollections())
if err != nil {
t.Fatalf("marshal investigation counters: %v", err)
}
for _, fragment := range []string{`"turn_count":3`, `"evidence_call_count":7`, `"tool_call_count":8`} {
if !strings.Contains(string(sessionPayload), fragment) {
t.Fatalf("investigation session missing %s: %s", fragment, sessionPayload)
}
}
}