140 lines
6.9 KiB
Go
140 lines
6.9 KiB
Go
package session
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
)
|
|
|
|
var statusNow = time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
|
|
|
|
// statusRec builds a session whose agent HAS delivered a hook signal; the
|
|
// no-signal cases below zero FirstSignalAt explicitly.
|
|
func statusRec(activity domain.ActivityState, terminated bool) domain.SessionRecord {
|
|
return domain.SessionRecord{
|
|
Activity: domain.Activity{State: activity, LastActivityAt: statusNow},
|
|
FirstSignalAt: statusNow,
|
|
IsTerminated: terminated,
|
|
}
|
|
}
|
|
|
|
// silentRec builds a live session that has never delivered a hook signal,
|
|
// seeded (spawned/restored) `age` before the derivation time.
|
|
func silentRec(age time.Duration) domain.SessionRecord {
|
|
return domain.SessionRecord{
|
|
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: statusNow.Add(-age)},
|
|
}
|
|
}
|
|
|
|
func statusPR(facts domain.PRFacts) []domain.PRFacts { return []domain.PRFacts{facts} }
|
|
|
|
func TestServiceDerivesStatusFromSessionFactsAndPR(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rec domain.SessionRecord
|
|
pr []domain.PRFacts
|
|
// hookless marks a harness with no activity pipeline (signalCapable
|
|
// false): silence is its permanent normal state, never no_signal.
|
|
hookless bool
|
|
want domain.SessionStatus
|
|
}{
|
|
{"terminated", statusRec(domain.ActivityExited, true), nil, false, domain.StatusTerminated},
|
|
{"merged-pr", statusRec(domain.ActivityIdle, true), statusPR(domain.PRFacts{Merged: true}), false, domain.StatusMerged},
|
|
{"needs-input", statusRec(domain.ActivityWaitingInput, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), false, domain.StatusNeedsInput},
|
|
{"ci-failed", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), false, domain.StatusCIFailed},
|
|
{"draft", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Draft: true}), false, domain.StatusDraft},
|
|
{"changes-requested", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewChangesRequest}), false, domain.StatusChangesRequested},
|
|
{"mergeable", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Mergeability: domain.MergeMergeable}), false, domain.StatusMergeable},
|
|
{"approved", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewApproved}), false, domain.StatusApproved},
|
|
{"review-pending", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewRequired}), false, domain.StatusReviewPending},
|
|
{"pr-open", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{}), false, domain.StatusPROpen},
|
|
{"working", statusRec(domain.ActivityActive, false), nil, false, domain.StatusWorking},
|
|
{"idle", statusRec(domain.ActivityIdle, false), nil, false, domain.StatusIdle},
|
|
|
|
// A live session whose hook-capable agent never signaled is no_signal
|
|
// once the grace passes — never a confident idle.
|
|
{"no-signal-after-grace", silentRec(2 * noSignalGrace), nil, false, domain.StatusNoSignal},
|
|
// A hook-less harness can never signal: its silence stays idle forever
|
|
// instead of degrading into a false "needs you".
|
|
{"hookless-silent-stays-idle", silentRec(2 * noSignalGrace), nil, true, domain.StatusIdle},
|
|
// Right after spawn the agent legitimately hasn't called back yet.
|
|
{"silent-within-grace-is-idle", silentRec(10 * time.Second), nil, false, domain.StatusIdle},
|
|
// Termination and PR facts outrank the missing-signal downgrade.
|
|
{
|
|
"no-signal-terminated-wins",
|
|
domain.SessionRecord{Activity: domain.Activity{State: domain.ActivityExited, LastActivityAt: statusNow.Add(-2 * noSignalGrace)}, IsTerminated: true},
|
|
nil,
|
|
false,
|
|
domain.StatusTerminated,
|
|
},
|
|
{"no-signal-pr-wins", silentRec(2 * noSignalGrace), statusPR(domain.PRFacts{}), false, domain.StatusPROpen},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := deriveStatus(tt.rec, tt.pr, statusNow, !tt.hookless); got != tt.want {
|
|
t.Fatalf("got %q want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A blocked stacked child cannot merge until its parent does, so its readiness
|
|
// signals are suppressed, but its problem signals (failing CI, draft,
|
|
// requested-changes/unresolved-comments) must still surface for the session.
|
|
func TestAggregateStackedChildSignals(t *testing.T) {
|
|
parent := domain.PRFacts{URL: "parent", SourceBranch: "feat", Mergeability: domain.MergeMergeable}
|
|
child := func(f domain.PRFacts) domain.PRFacts {
|
|
f.URL = "child"
|
|
f.SourceBranch = "feat/child"
|
|
f.TargetBranch = "feat"
|
|
return f
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
prs []domain.PRFacts
|
|
want domain.SessionStatus
|
|
}{
|
|
{"blocked-child-ci-failing-surfaces", []domain.PRFacts{parent, child(domain.PRFacts{CI: domain.CIFailing})}, domain.StatusCIFailed},
|
|
{"blocked-child-draft-surfaces", []domain.PRFacts{parent, child(domain.PRFacts{Draft: true})}, domain.StatusDraft},
|
|
{"blocked-child-changes-requested-surfaces", []domain.PRFacts{parent, child(domain.PRFacts{Review: domain.ReviewChangesRequest})}, domain.StatusChangesRequested},
|
|
{"blocked-child-unresolved-comments-surfaces", []domain.PRFacts{parent, child(domain.PRFacts{ReviewComments: true})}, domain.StatusChangesRequested},
|
|
// A blocked child's readiness signals stay hidden: only the parent's
|
|
// mergeable state drives the session.
|
|
{"blocked-child-mergeable-suppressed", []domain.PRFacts{parent, child(domain.PRFacts{Mergeability: domain.MergeMergeable})}, domain.StatusMergeable},
|
|
{"blocked-child-approved-suppressed", []domain.PRFacts{parent, child(domain.PRFacts{Review: domain.ReviewApproved})}, domain.StatusMergeable},
|
|
// Degenerate set where every open PR is blocked and none is actionable:
|
|
// fall back to the raw aggregate so the session never goes dark.
|
|
{
|
|
"all-blocked-no-actionable-falls-back",
|
|
[]domain.PRFacts{
|
|
{URL: "a", SourceBranch: "feat/a", TargetBranch: "feat/b", Mergeability: domain.MergeMergeable},
|
|
{URL: "b", SourceBranch: "feat/b", TargetBranch: "feat/a", Mergeability: domain.MergeMergeable},
|
|
},
|
|
domain.StatusMergeable,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := deriveStatus(statusRec(domain.ActivityIdle, false), tt.prs, statusNow, true); got != tt.want {
|
|
t.Fatalf("got %q want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Without an injected capability predicate the service must never claim
|
|
// no_signal; with one, capability follows the predicate per harness.
|
|
func TestHarnessSignalsCapabilityGate(t *testing.T) {
|
|
if (&Service{}).harnessSignals(domain.HarnessCodex) {
|
|
t.Fatal("zero-value Service reports signal-capable; want incapable (never no_signal)")
|
|
}
|
|
s := NewWithDeps(Deps{SignalCapable: func(h domain.AgentHarness) bool { return h == domain.HarnessCodex }})
|
|
if !s.harnessSignals(domain.HarnessCodex) {
|
|
t.Fatal("harnessSignals(codex) = false with codex-capable predicate")
|
|
}
|
|
if s.harnessSignals(domain.HarnessAmp) {
|
|
t.Fatal("harnessSignals(amp) = true with codex-only predicate")
|
|
}
|
|
}
|