agent-orchestrator/backend/internal/service/session/status_test.go

96 lines
4.6 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 &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)
}
})
}
}
// 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")
}
}