Files
boshu2__agentops/cli/cmd/ao/rpi_phased_handoff_test.go
T
Boden Fuller 2b338ee0c3 fix(cli+docs): tech debt sweep — UTF-8 truncation, CORS, stale refs (na-7r3)
Go fixes:
- Fix UTF-8 boundary slicing in narrative truncation (rune-safe truncateRunes helper)
- Add signal.Stop to rpi_serve signal handlers (prevent goroutine leak)
- Replace CORS wildcard with localhost-only validation (prevent cross-site reads)
- Surface SSE polling errors to client instead of silent continue
- Extract section name constants (HISTORY/INTEL/TASK) from magic strings
- Add intel_scope enum validation (none/topic/full)
- Sort resolveSkillPath glob matches for deterministic resolution
- Add random suffix to adhoc context IDs (prevent 1-second collision)
- Surface writePromptAuditTrail errors to stderr unconditionally
- Document RPI_RUN_ID env var in ao inject --help

Doc fixes:
- Replace stale /learn skill references with /retro --quick across 9 workflow docs
- Replace .agents/retros/ with .agents/council/ in post-mortem SKILL.md + references
- Reorder GOALS.md directives to sequential 1-8 numbering
- Rename post-mortem Phase 2 steps from 4.x to EX.x
- Update SKILL-TIERS.md: council + retro → council + knowledge lifecycle
- Sync all changes to skills-codex mirror

Epic: na-7r3 (6 issues, 2 waves)
2026-03-04 10:07:01 -05:00

678 lines
19 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWritePhaseHandoff_RoundTrip(t *testing.T) {
dir := t.TempDir()
h := &phaseHandoff{
SchemaVersion: 1,
RunID: "test-run",
Phase: 1,
PhaseName: "discovery",
Status: "completed",
Goal: "test goal",
EpicID: "ag-123",
Verdicts: map[string]string{"pre_mortem": "PASS"},
ArtifactsProduced: []string{"plan.md"},
DecisionsMade: []string{"use JWT"},
OpenRisks: []string{"migration downtime"},
DurationSeconds: 312.5,
ToolCalls: 42,
Narrative: "Discovery completed successfully.",
CompletedAt: "2026-03-02T12:00:00Z",
}
if err := writePhaseHandoff(dir, h); err != nil {
t.Fatalf("writePhaseHandoff: %v", err)
}
got, err := readPhaseHandoff(dir, 1)
if err != nil {
t.Fatalf("readPhaseHandoff: %v", err)
}
if got.RunID != h.RunID {
t.Errorf("RunID = %q, want %q", got.RunID, h.RunID)
}
if got.Goal != h.Goal {
t.Errorf("Goal = %q, want %q", got.Goal, h.Goal)
}
if got.EpicID != h.EpicID {
t.Errorf("EpicID = %q, want %q", got.EpicID, h.EpicID)
}
if got.Status != h.Status {
t.Errorf("Status = %q, want %q", got.Status, h.Status)
}
if got.DurationSeconds != h.DurationSeconds {
t.Errorf("DurationSeconds = %f, want %f", got.DurationSeconds, h.DurationSeconds)
}
if v, ok := got.Verdicts["pre_mortem"]; !ok || v != "PASS" {
t.Errorf("Verdicts[pre_mortem] = %q, want PASS", v)
}
if len(got.ArtifactsProduced) != 1 || got.ArtifactsProduced[0] != "plan.md" {
t.Errorf("ArtifactsProduced = %v, want [plan.md]", got.ArtifactsProduced)
}
if len(got.DecisionsMade) != 1 || got.DecisionsMade[0] != "use JWT" {
t.Errorf("DecisionsMade = %v, want [use JWT]", got.DecisionsMade)
}
if len(got.OpenRisks) != 1 || got.OpenRisks[0] != "migration downtime" {
t.Errorf("OpenRisks = %v, want [migration downtime]", got.OpenRisks)
}
if got.ToolCalls != h.ToolCalls {
t.Errorf("ToolCalls = %d, want %d", got.ToolCalls, h.ToolCalls)
}
if got.Narrative != h.Narrative {
t.Errorf("Narrative = %q, want %q", got.Narrative, h.Narrative)
}
if got.CompletedAt != h.CompletedAt {
t.Errorf("CompletedAt = %q, want %q", got.CompletedAt, h.CompletedAt)
}
}
func TestReadPhaseHandoff_MissingSummary(t *testing.T) {
dir := t.TempDir()
// No files exist
_, err := readPhaseHandoff(dir, 1)
if err == nil {
t.Fatal("expected error for missing handoff and summary")
}
}
func TestReadAllHandoffs_Mixed(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// Write handoff for phase 1
h1 := &phaseHandoff{SchemaVersion: 1, Phase: 1, PhaseName: "discovery", Status: "completed", Goal: "test"}
if err := writePhaseHandoff(dir, h1); err != nil {
t.Fatal(err)
}
// Write handoff for phase 2
h2 := &phaseHandoff{SchemaVersion: 1, Phase: 2, PhaseName: "implementation", Status: "completed", Goal: "test"}
if err := writePhaseHandoff(dir, h2); err != nil {
t.Fatal(err)
}
// Phase 3 missing — should skip
handoffs, err := readAllHandoffs(dir, 4)
if err != nil {
t.Fatalf("readAllHandoffs: %v", err)
}
if len(handoffs) != 2 {
t.Errorf("got %d handoffs, want 2", len(handoffs))
}
}
func TestReadAllHandoffs_NoHandoffs(t *testing.T) {
dir := t.TempDir()
_, err := readAllHandoffs(dir, 3)
if err == nil {
t.Fatal("expected error when no handoffs exist")
}
}
func TestReadPhaseHandoff_LegacyFallback(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// Write a legacy summary file (no handoff.json)
summaryPath := filepath.Join(rpiDir, "phase-1-summary.md")
if err := os.WriteFile(summaryPath, []byte("# Phase 1 Summary\nDiscovery completed."), 0o644); err != nil {
t.Fatal(err)
}
h, err := readPhaseHandoff(dir, 1)
if err != nil {
t.Fatalf("readPhaseHandoff with legacy fallback: %v", err)
}
if h.Phase != 1 {
t.Errorf("Phase = %d, want 1", h.Phase)
}
if h.PhaseName != "discovery" {
t.Errorf("PhaseName = %q, want discovery", h.PhaseName)
}
if h.Narrative == "" {
t.Error("expected narrative from legacy summary")
}
if h.Status != "completed" {
t.Errorf("Status = %q, want completed", h.Status)
}
if h.SchemaVersion != 1 {
t.Errorf("SchemaVersion = %d, want 1", h.SchemaVersion)
}
}
func TestBuildHandoffContext_Formatting(t *testing.T) {
handoffs := []*phaseHandoff{
{
Phase: 1, PhaseName: "discovery", Status: "completed",
Goal: "add auth", EpicID: "ag-123",
DurationSeconds: 312,
Verdicts: map[string]string{"pre_mortem": "PASS"},
ArtifactsProduced: []string{"plan.md"},
DecisionsMade: []string{"use JWT"},
OpenRisks: []string{"migration downtime"},
Narrative: "Discovery done.",
},
}
allFieldsManifest := phaseManifest{Phase: 2, NarrativeCap: 1000}
ctx := buildHandoffContext(handoffs, allFieldsManifest)
if ctx == "" {
t.Fatal("expected non-empty context")
}
// Check required sections
checks := []string{
"RPI Context",
"Goal: add auth",
"Phase 1: discovery",
"ag-123",
"pre_mortem PASS",
"plan.md",
"use JWT",
"migration downtime",
"Discovery done.",
}
for _, check := range checks {
if !strings.Contains(ctx, check) {
t.Errorf("context missing %q\ngot:\n%s", check, ctx)
}
}
}
func TestBuildHandoffContext_MultiPhase(t *testing.T) {
handoffs := []*phaseHandoff{
{
Phase: 1, PhaseName: "discovery", Status: "completed",
Goal: "add auth",
},
{
Phase: 2, PhaseName: "implementation", Status: "time_boxed",
Goal: "add auth",
DurationSeconds: 5400,
},
}
allFieldsManifest := phaseManifest{Phase: 2, NarrativeCap: 1000}
ctx := buildHandoffContext(handoffs, allFieldsManifest)
if !strings.Contains(ctx, "Phase 1: discovery") {
t.Error("missing phase 1")
}
if !strings.Contains(ctx, "Phase 2: implementation") {
t.Error("missing phase 2")
}
if !strings.Contains(ctx, "time_boxed") {
t.Error("missing time_boxed status")
}
if !strings.Contains(ctx, "5400s") {
t.Error("missing duration")
}
}
func TestBuildPhaseHandoffFromState(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// Write a summary file
if err := os.WriteFile(filepath.Join(rpiDir, "phase-1-summary.md"), []byte("Phase 1 done."), 0o644); err != nil {
t.Fatal(err)
}
state := &phasedState{
RunID: "test-run",
Goal: "test goal",
EpicID: "ag-456",
Verdicts: map[string]string{"pre_mortem": "WARN"},
}
h := buildPhaseHandoffFromState(state, 1, dir)
if h.RunID != "test-run" {
t.Errorf("RunID = %q, want test-run", h.RunID)
}
if h.Goal != "test goal" {
t.Errorf("Goal = %q, want test goal", h.Goal)
}
if h.EpicID != "ag-456" {
t.Errorf("EpicID = %q, want ag-456", h.EpicID)
}
if v := h.Verdicts["pre_mortem"]; v != "WARN" {
t.Errorf("Verdicts[pre_mortem] = %q, want WARN", v)
}
if h.Narrative != "Phase 1 done." {
t.Errorf("Narrative = %q, want 'Phase 1 done.'", h.Narrative)
}
if h.CompletedAt == "" {
t.Error("expected CompletedAt to be set")
}
if h.Status != "completed" {
t.Errorf("Status = %q, want completed", h.Status)
}
}
func TestBuildPhaseHandoffFromState_WithPhaseResult(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// Write a phase result with time_boxed status
pr := &phaseResult{
SchemaVersion: 1,
RunID: "test-run",
Phase: 2,
PhaseName: "implementation",
Status: "time_boxed",
DurationSeconds: 5400,
}
if err := writePhaseResult(dir, pr); err != nil {
t.Fatal(err)
}
state := &phasedState{
RunID: "test-run",
Goal: "test goal",
Verdicts: map[string]string{},
}
h := buildPhaseHandoffFromState(state, 2, dir)
if h.Status != "time_boxed" {
t.Errorf("Status = %q, want time_boxed", h.Status)
}
if h.DurationSeconds != 5400 {
t.Errorf("DurationSeconds = %f, want 5400", h.DurationSeconds)
}
}
func TestWritePhaseHandoff_AtomicWrite(t *testing.T) {
dir := t.TempDir()
h := &phaseHandoff{SchemaVersion: 1, Phase: 2, PhaseName: "implementation", Status: "completed"}
if err := writePhaseHandoff(dir, h); err != nil {
t.Fatalf("write: %v", err)
}
// Verify no .tmp file remains
tmpPath := filepath.Join(dir, ".agents", "rpi", "phase-2-handoff.json.tmp")
if _, err := os.Stat(tmpPath); err == nil {
t.Error("tmp file should not remain after atomic write")
}
// Verify file is valid JSON
data, err := os.ReadFile(filepath.Join(dir, ".agents", "rpi", "phase-2-handoff.json"))
if err != nil {
t.Fatalf("read output: %v", err)
}
var parsed phaseHandoff
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed.Phase != 2 {
t.Errorf("parsed Phase = %d, want 2", parsed.Phase)
}
}
func TestBuildHandoffContext_Empty(t *testing.T) {
ctx := buildHandoffContext(nil, phaseManifest{})
if ctx != "" {
t.Errorf("expected empty context for nil handoffs, got %q", ctx)
}
}
func TestBuildHandoffContext_EmptySlice(t *testing.T) {
ctx := buildHandoffContext([]*phaseHandoff{}, phaseManifest{})
if ctx != "" {
t.Errorf("expected empty context for empty handoffs, got %q", ctx)
}
}
func TestBuildHandoffContext_NarrativeTruncation(t *testing.T) {
longNarrative := strings.Repeat("x", 2000)
handoffs := []*phaseHandoff{
{
Phase: 1, PhaseName: "discovery", Status: "completed",
Narrative: longNarrative,
},
}
ctx := buildHandoffContext(handoffs, phaseManifest{NarrativeCap: 1000})
// Narrative should be capped at 1000 chars + "..."
if !strings.Contains(ctx, "...") {
t.Error("expected truncation marker for long narrative")
}
// The full 2000-char narrative should NOT appear
if strings.Contains(ctx, longNarrative) {
t.Error("full 2000-char narrative should be truncated")
}
}
func TestBuildHandoffContext_DeterministicVerdictOrder(t *testing.T) {
handoffs := []*phaseHandoff{
{
Phase: 1, PhaseName: "discovery", Status: "completed",
Verdicts: map[string]string{
"vibe": "PASS",
"pre_mortem": "WARN",
"crank": "PASS",
},
},
}
manifest := phaseManifest{Phase: 2, NarrativeCap: 0}
// Run 10 times to catch non-determinism
first := buildHandoffContext(handoffs, manifest)
for i := 0; i < 10; i++ {
got := buildHandoffContext(handoffs, manifest)
if got != first {
t.Fatalf("non-deterministic output on iteration %d:\nfirst:\n%s\ngot:\n%s", i, first, got)
}
}
// Verify sorted order: crank, pre_mortem, vibe
if !strings.Contains(first, "crank PASS, pre_mortem WARN, vibe PASS") {
t.Errorf("verdicts not in sorted order\ngot:\n%s", first)
}
}
func TestRenderHandoffField_StringSlice(t *testing.T) {
got := renderHandoffField("Artifacts", []string{"plan.md", "auth.go"})
if got != "Artifacts: plan.md, auth.go\n" {
t.Errorf("renderHandoffField = %q, want 'Artifacts: plan.md, auth.go\\n'", got)
}
}
func TestRenderHandoffField_EmptySlice(t *testing.T) {
got := renderHandoffField("Artifacts", []string{})
if got != "" {
t.Errorf("renderHandoffField for empty slice = %q, want empty", got)
}
}
func TestRenderHandoffField_String(t *testing.T) {
got := renderHandoffField("Epic", "ag-123")
if got != "Epic: ag-123\n" {
t.Errorf("renderHandoffField = %q, want 'Epic: ag-123\\n'", got)
}
}
func TestRenderHandoffField_EmptyString(t *testing.T) {
got := renderHandoffField("Epic", "")
if got != "" {
t.Errorf("renderHandoffField for empty string = %q, want empty", got)
}
}
func TestTruncateRunes_MultiByteUTF8(t *testing.T) {
// Mix of ASCII and multi-byte characters (Japanese + emoji)
input := "Hello 世界! 🌍🌎🌏 more text here"
got := truncateRunes(input, 10)
// Should be first 10 runes + "..."
runes := []rune(input)
want := string(runes[:10]) + "..."
if got != want {
t.Errorf("truncateRunes(%q, 10) = %q, want %q", input, got, want)
}
// Verify result is valid UTF-8
for i, r := range got {
if r == '\uFFFD' {
t.Errorf("truncateRunes produced invalid UTF-8 at byte %d", i)
}
}
}
func TestTruncateRunes_ShortString(t *testing.T) {
input := "short"
got := truncateRunes(input, 100)
if got != input {
t.Errorf("truncateRunes(%q, 100) = %q, want %q (no truncation)", input, got, input)
}
}
func TestFormatVerdicts_Sorted(t *testing.T) {
verdicts := map[string]string{
"zebra": "FAIL",
"alpha": "PASS",
"mid": "WARN",
}
got := formatVerdicts(verdicts)
want := "Verdict: alpha PASS, mid WARN, zebra FAIL\n"
if got != want {
t.Errorf("formatVerdicts = %q, want %q", got, want)
}
}
func TestFormatVerdicts_Empty(t *testing.T) {
got := formatVerdicts(nil)
if got != "" {
t.Errorf("formatVerdicts(nil) = %q, want empty", got)
}
got = formatVerdicts(map[string]string{})
if got != "" {
t.Errorf("formatVerdicts({}) = %q, want empty", got)
}
}
func TestDiscoverPhaseArtifacts_Discovery(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
plansDir := filepath.Join(dir, ".agents", "plans")
councilDir := filepath.Join(dir, ".agents", "council")
for _, d := range []string{rpiDir, plansDir, councilDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// Create phase summary
os.WriteFile(filepath.Join(rpiDir, "phase-1-summary.md"), []byte("summary"), 0o644)
// Create plan
os.WriteFile(filepath.Join(plansDir, "plan.md"), []byte("plan"), 0o644)
// Create pre-mortem report
os.WriteFile(filepath.Join(councilDir, "2026-03-02-pre-mortem-auth.md"), []byte("report"), 0o644)
artifacts := discoverPhaseArtifacts(dir, 1)
if len(artifacts) < 3 {
t.Errorf("expected at least 3 artifacts, got %d: %v", len(artifacts), artifacts)
}
}
func TestDiscoverPhaseArtifacts_Validation(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
councilDir := filepath.Join(dir, ".agents", "council")
for _, d := range []string{rpiDir, councilDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// Create vibe report
os.WriteFile(filepath.Join(councilDir, "2026-03-02-vibe-recent.md"), []byte("vibe"), 0o644)
artifacts := discoverPhaseArtifacts(dir, 3)
found := false
for _, a := range artifacts {
if strings.Contains(a, "vibe") {
found = true
}
}
if !found {
t.Errorf("expected vibe artifact, got %v", artifacts)
}
}
func TestDiscoverPhaseArtifacts_NoArtifacts(t *testing.T) {
dir := t.TempDir()
artifacts := discoverPhaseArtifacts(dir, 1)
if len(artifacts) != 0 {
t.Errorf("expected no artifacts, got %v", artifacts)
}
}
func TestReadPhaseHandoff_CorruptJSON(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// Write invalid JSON
jsonPath := filepath.Join(rpiDir, "phase-1-handoff.json")
if err := os.WriteFile(jsonPath, []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
_, err := readPhaseHandoff(dir, 1)
if err == nil {
t.Fatal("expected error for corrupt JSON")
}
if !strings.Contains(err.Error(), "parse handoff") {
t.Errorf("expected 'parse handoff' error, got: %v", err)
}
}
func TestWritePhaseHandoff_CreatesDirectories(t *testing.T) {
dir := t.TempDir()
// .agents/rpi/ does not exist yet
h := &phaseHandoff{SchemaVersion: 1, Phase: 1, PhaseName: "discovery", Status: "completed"}
if err := writePhaseHandoff(dir, h); err != nil {
t.Fatalf("writePhaseHandoff should create directories: %v", err)
}
// Verify file exists
jsonPath := filepath.Join(dir, ".agents", "rpi", "phase-1-handoff.json")
if _, err := os.Stat(jsonPath); err != nil {
t.Errorf("handoff file not created: %v", err)
}
}
func TestBuildPhaseHandoffFromState_ContextDegradation(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
state := &phasedState{
RunID: "test-run",
Goal: "test goal",
Verdicts: map[string]string{},
}
// Build handoff for phase 2 WITHOUT writing a phase-1-handoff.json first
h := buildPhaseHandoffFromState(state, 2, dir)
if !h.ContextDegradation {
t.Error("expected ContextDegradation=true when phase-1-handoff.json is missing")
}
// Now write phase-1 handoff and rebuild phase-2 handoff
h1 := &phaseHandoff{SchemaVersion: 1, Phase: 1, PhaseName: "discovery", Status: "completed"}
if err := writePhaseHandoff(dir, h1); err != nil {
t.Fatal(err)
}
h = buildPhaseHandoffFromState(state, 2, dir)
if h.ContextDegradation {
t.Error("expected ContextDegradation=false when phase-1-handoff.json exists")
}
// Phase 1 should never have degradation (no prior phase)
h = buildPhaseHandoffFromState(state, 1, dir)
if h.ContextDegradation {
t.Error("expected ContextDegradation=false for phase 1 (no prior phase)")
}
}
func TestHandoffDetected_ChecksJsonFile(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// No file — should return false
if handoffDetected(dir, 1) {
t.Error("expected false when no handoff file exists")
}
// Write .md file — should NOT trigger detection (bug fix validates .json)
mdPath := filepath.Join(rpiDir, "phase-1-handoff.md")
if err := os.WriteFile(mdPath, []byte("legacy"), 0o644); err != nil {
t.Fatal(err)
}
if handoffDetected(dir, 1) {
t.Error("expected false when only .md file exists (should check .json)")
}
// Write .json file — should return true
jsonPath := filepath.Join(rpiDir, "phase-1-handoff.json")
if err := os.WriteFile(jsonPath, []byte(`{"phase":1}`), 0o644); err != nil {
t.Fatal(err)
}
if !handoffDetected(dir, 1) {
t.Error("expected true when phase-1-handoff.json exists")
}
}
func TestBuildHandoffContext_DegradationWarning(t *testing.T) {
handoffs := []*phaseHandoff{
{
Phase: 2, PhaseName: "implementation", Status: "completed",
ContextDegradation: true,
},
}
ctx := buildHandoffContext(handoffs, phaseManifest{Phase: 3, NarrativeCap: 0})
if !strings.Contains(ctx, "CONTEXT DEGRADATION") {
t.Error("expected CONTEXT DEGRADATION warning in output")
}
if !strings.Contains(ctx, "Phase 1 handoff was missing") {
t.Errorf("expected 'Phase 1 handoff was missing' in output, got:\n%s", ctx)
}
}
func TestReadPhaseHandoff_PrefersJSONOverLegacy(t *testing.T) {
dir := t.TempDir()
rpiDir := filepath.Join(dir, ".agents", "rpi")
if err := os.MkdirAll(rpiDir, 0o755); err != nil {
t.Fatal(err)
}
// Write both JSON handoff and legacy summary
h := &phaseHandoff{
SchemaVersion: 1, Phase: 1, PhaseName: "discovery",
Status: "completed", Goal: "from-json",
}
if err := writePhaseHandoff(dir, h); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rpiDir, "phase-1-summary.md"), []byte("from-legacy"), 0o644); err != nil {
t.Fatal(err)
}
got, err := readPhaseHandoff(dir, 1)
if err != nil {
t.Fatalf("readPhaseHandoff: %v", err)
}
// Should prefer JSON
if got.Goal != "from-json" {
t.Errorf("Goal = %q, want from-json (JSON should take precedence)", got.Goal)
}
}