190cf0c794
Two ecosystem defects, both of the shape where a call reports done and nothing of the sort happened. entityAttentionCapability surfaced every item it spoke and remembered none of them, so the previous digest stayed the positional memory. A follow-up "отметь второй как сделанное" then indexed into a list he had not just heard and transitioned somebody else's item, which is the exact harm the position resolver exists to prevent. A parked Hexis confirm did not carry the correlation id of the action that proposed it. The confirm lands on a later turn with a context of its own, so the execution recorded a fresh id and an empty causation: the resolve, the discovery and the thing they authorised sat in the trace as three unrelated calls. The contract mints one id per action.
198 lines
7.3 KiB
Go
198 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// These tests exercise the fake ecosystem harness (fakeecosystem_test.go)
|
|
// directly, covering paths the ad-hoc httptest servers in ecosystem_test.go
|
|
// don't: Praxis attention (happy + degraded) and fault injection against a
|
|
// reusable fake rather than a one-off inline handler.
|
|
|
|
func praxisActDec(fn string) router.Decision {
|
|
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
|
}
|
|
|
|
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
|
// in the value slot. Without one they answer "which item?" and never reach
|
|
// Praxis at all, which makes them useless for testing a Praxis outage.
|
|
func praxisItemDec(fn, itemID string) router.Decision {
|
|
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true, Value: itemID}}
|
|
}
|
|
|
|
func newPraxisTestHandler(t *testing.T, praxis *fakeServer) *reactiveHandler {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
clock := newFakeClock(time.Now())
|
|
return &reactiveHandler{
|
|
api: ipc.NewStoreAPI(st),
|
|
dataStore: st,
|
|
now: clock.Now,
|
|
ecosystem: &ecosystemWiring{praxis: newPraxisClient(praxis.URL)},
|
|
}
|
|
}
|
|
|
|
func TestPraxisAttention_HappyPathSurfacesItems(t *testing.T) {
|
|
ctx := context.Background()
|
|
items := fixturePraxisAttentionItems(map[string]any{
|
|
"id": "item_1", "title": "disk almost full", "importance": 3.0, "rule": "low_disk",
|
|
})
|
|
praxis := newFakePraxis(t, items)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
|
if !strings.Contains(reply, "disk almost full") {
|
|
t.Fatalf("expected attention digest to mention the item, got %q", reply)
|
|
}
|
|
|
|
var sawAttention, sawSurface bool
|
|
for _, r := range praxis.Requests() {
|
|
if r.Method == "GET" && strings.HasPrefix(r.Path, "/api/v1/tools/attention") {
|
|
sawAttention = true
|
|
}
|
|
if r.Method == "POST" && r.Path == "/api/v1/tools/surface" {
|
|
sawSurface = true
|
|
}
|
|
}
|
|
if !sawAttention {
|
|
t.Error("expected a GET to /api/v1/tools/attention")
|
|
}
|
|
if !sawSurface {
|
|
t.Error("expected surfaced item to POST /api/v1/tools/surface (surfaced != acknowledged)")
|
|
}
|
|
}
|
|
|
|
// TestPraxisAttention_DegradedFailsClosedNotEmpty covers the degraded-mode
|
|
// contract: when Praxis is down, Maven must say so rather than silently
|
|
// returning nothing or panicking.
|
|
func TestPraxisAttention_DegradedFailsClosedNotEmpty(t *testing.T) {
|
|
ctx := context.Background()
|
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
|
praxis.SetFault(500)
|
|
h := newPraxisTestHandler(t, praxis)
|
|
|
|
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
|
if reply == "" {
|
|
t.Fatal("praxis outage must not produce an empty reply")
|
|
}
|
|
if strings.Contains(reply, "disk almost full") {
|
|
t.Fatal("degraded reply must not fabricate item content")
|
|
}
|
|
}
|
|
|
|
// TestFakeNexus_FaultInjectionThenRecovery demonstrates the shared harness's
|
|
// fault toggle affecting the same running server, matching the shape of a
|
|
// real dependency flapping and recovering mid-session.
|
|
func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_status", "name": "restart", "read_only": true})
|
|
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
|
|
|
st := newTestStore(t)
|
|
h := &reactiveHandler{
|
|
api: ipc.NewStoreAPI(st),
|
|
dataStore: st,
|
|
now: time.Now,
|
|
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
|
}
|
|
|
|
nexus.SetFault(503)
|
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
if actRan(reply) {
|
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
|
}
|
|
|
|
nexus.SetFault(0)
|
|
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
if !actRan(reply) {
|
|
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
|
}
|
|
}
|
|
|
|
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
|
// she read out, so a positional follow-up must land on one of ITS items. It
|
|
// surfaced them and remembered none, which left the previous digest live and
|
|
// sent "отметь второй" at somebody else's item.
|
|
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
|
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
|
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
|
praxis := newFakePraxis(t, scoped)
|
|
h := ecoHandler(t, nexus, praxis, nil)
|
|
|
|
// A digest from an earlier turn, still the positional memory.
|
|
h.rememberSurfaced([]string{"item_stale"})
|
|
|
|
reply := h.handlePraxisAct(ctx, router.Decision{
|
|
Intent: router.IntentAct,
|
|
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
|
})
|
|
if !strings.Contains(reply, "indexer wedged") {
|
|
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
|
}
|
|
|
|
h.mu.Lock()
|
|
surfaced := append([]string(nil), h.surfacedItems...)
|
|
h.mu.Unlock()
|
|
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
|
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
|
}
|
|
|
|
// The follow-up resolves against what he just heard, not the stale list.
|
|
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
|
t.Fatal("positional follow-up should have been claimed by praxis")
|
|
}
|
|
var body string
|
|
for _, r := range praxis.Requests() {
|
|
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
|
body = string(r.Body)
|
|
}
|
|
}
|
|
if !strings.Contains(body, "item_scoped_1") {
|
|
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
|
}
|
|
if strings.Contains(body, "item_stale") {
|
|
t.Fatal("resolve transitioned an item from a previous digest")
|
|
}
|
|
}
|
|
|
|
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
|
// later turn with a context of its own. The contract mints one id per action,
|
|
// so the execution it authorises must still be joinable to the resolve and the
|
|
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
|
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
|
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
|
h := ecoHandler(t, nexus, nil, hexis)
|
|
|
|
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
|
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
|
}
|
|
resolve := findTrace(t, h, "nexus", "resolve")
|
|
if resolve == nil || resolve.CorrelationID == "" {
|
|
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
|
}
|
|
|
|
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
|
t.Fatal("confirm should have been claimed")
|
|
}
|
|
exec := findTrace(t, h, "hexis", "execute")
|
|
if exec == nil {
|
|
t.Fatal("expected a hexis execute trace")
|
|
}
|
|
if exec.CausationID != resolve.CorrelationID {
|
|
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
|
exec.CausationID, resolve.CorrelationID)
|
|
}
|
|
}
|