agent-orchestrator/backend/internal/session/fakes_test.go

408 lines
11 KiB
Go

package session
import (
"context"
"fmt"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// callLog records the cross-fake call order so tests can assert pipeline
// sequencing (e.g. OnKillRequested before Runtime.Destroy before Workspace.Destroy).
type callLog struct {
mu sync.Mutex
calls []string
}
func (c *callLog) add(s string) {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = append(c.calls, s)
}
func (c *callLog) snapshot() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, len(c.calls))
copy(out, c.calls)
return out
}
// indexOf returns the position of the first call equal to name, or -1.
func (c *callLog) indexOf(name string) int {
for i, s := range c.snapshot() {
if s == name {
return i
}
}
return -1
}
// ---- fakeStore: in-memory LifecycleStore with faithful merge-patch + Seed/Get ----
type fakeStore struct {
mu sync.Mutex
records map[domain.SessionID]*domain.SessionRecord
metadata map[domain.SessionID]map[string]string
}
var _ ports.LifecycleStore = (*fakeStore)(nil)
func newFakeStore() *fakeStore {
return &fakeStore{
records: map[domain.SessionID]*domain.SessionRecord{},
metadata: map[domain.SessionID]map[string]string{},
}
}
func (s *fakeStore) Seed(_ context.Context, rec domain.SessionRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.records[rec.ID]; ok {
return fmt.Errorf("seed: session %s already exists", rec.ID)
}
if rec.Lifecycle.Version == 0 {
rec.Lifecycle.Version = domain.LifecycleVersion
}
r := rec
s.records[rec.ID] = &r
return nil
}
func (s *fakeStore) Get(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.records[id]
if !ok {
return domain.SessionRecord{}, false, nil
}
return s.withMetadata(*rec), true, nil
}
func (s *fakeStore) Load(_ context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.records[id]
if !ok {
return domain.CanonicalSessionLifecycle{}, false, nil
}
return rec.Lifecycle, true, nil
}
func (s *fakeStore) PatchLifecycle(_ context.Context, id domain.SessionID, p ports.LifecyclePatch) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.records[id]
if !ok {
rec = &domain.SessionRecord{ID: id, Lifecycle: domain.CanonicalSessionLifecycle{Version: domain.LifecycleVersion}}
s.records[id] = rec
}
l := &rec.Lifecycle
if p.ExpectedRevision != nil && *p.ExpectedRevision != l.Revision {
return fmt.Errorf("revision mismatch for %s: have %d, expected %d", id, l.Revision, *p.ExpectedRevision)
}
if p.Session != nil {
l.Session = *p.Session
}
if p.PR != nil {
l.PR = *p.PR
}
if p.Runtime != nil {
l.Runtime = *p.Runtime
}
if p.Activity != nil {
l.Activity = *p.Activity
}
switch {
case p.ClearDetecting:
l.Detecting = nil
case p.Detecting != nil:
d := *p.Detecting
l.Detecting = &d
}
l.Version = domain.LifecycleVersion
l.Revision++
rec.UpdatedAt = time.Now()
return nil
}
func (s *fakeStore) List(_ context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []domain.SessionRecord
for _, rec := range s.records {
if rec.ProjectID == project {
out = append(out, s.withMetadata(*rec))
}
}
return out, nil
}
func (s *fakeStore) GetMetadata(_ context.Context, id domain.SessionID) (map[string]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return cloneMap(s.metadata[id]), nil
}
func (s *fakeStore) PatchMetadata(_ context.Context, id domain.SessionID, kv map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.metadata[id] == nil {
s.metadata[id] = map[string]string{}
}
for k, v := range kv {
s.metadata[id][k] = v
}
return nil
}
// withMetadata attaches the separately-stored metadata to a record copy (a real
// store would return them together). Caller holds s.mu.
func (s *fakeStore) withMetadata(rec domain.SessionRecord) domain.SessionRecord {
if md := s.metadata[rec.ID]; len(md) > 0 {
rec.Metadata = cloneMap(md)
}
return rec
}
// ---- fakeRuntime ----
type fakeRuntime struct {
log *callLog
createErr error
alive bool
created []ports.RuntimeConfig
destroyed []ports.RuntimeHandle
sent []string
}
var _ ports.Runtime = (*fakeRuntime)(nil)
func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
r.log.add("Runtime.Create")
if r.createErr != nil {
return ports.RuntimeHandle{}, r.createErr
}
r.created = append(r.created, cfg)
return ports.RuntimeHandle{ID: "rt-" + string(cfg.SessionID), RuntimeName: "tmux"}, nil
}
func (r *fakeRuntime) Destroy(_ context.Context, h ports.RuntimeHandle) error {
r.log.add("Runtime.Destroy")
r.destroyed = append(r.destroyed, h)
return nil
}
func (r *fakeRuntime) SendMessage(_ context.Context, _ ports.RuntimeHandle, message string) error {
r.sent = append(r.sent, message)
return nil
}
func (r *fakeRuntime) GetOutput(_ context.Context, _ ports.RuntimeHandle, _ int) (string, error) {
return "", nil
}
func (r *fakeRuntime) IsAlive(_ context.Context, _ ports.RuntimeHandle) (bool, error) {
return r.alive, nil
}
// ---- fakeAgent ----
type fakeAgent struct {
env map[string]string
}
var _ ports.Agent = (*fakeAgent)(nil)
func (a *fakeAgent) GetLaunchCommand(_ ports.AgentConfig) string { return "claude" }
func (a *fakeAgent) GetEnvironment(_ ports.AgentConfig) map[string]string { return cloneMap(a.env) }
func (a *fakeAgent) ProbeProcess(_ context.Context, _ ports.RuntimeHandle) (ports.ProcessProbe, error) {
return ports.ProcessProbeAlive, nil
}
func (a *fakeAgent) GetRestoreCommand(agentSessionID string) string {
return "claude --resume " + agentSessionID
}
// ---- fakeWorkspace (with worktree-remove refusal mode) ----
type fakeWorkspace struct {
log *callLog
createErr error
refuse map[string]bool // path -> still registered after prune (uncommitted work)
created []ports.WorkspaceConfig
destroyed []ports.WorkspaceInfo
restoredID []domain.SessionID
}
var _ ports.Workspace = (*fakeWorkspace)(nil)
func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
w.log.add("Workspace.Create")
if w.createErr != nil {
return ports.WorkspaceInfo{}, w.createErr
}
w.created = append(w.created, cfg)
return workspaceFor(cfg), nil
}
func (w *fakeWorkspace) Destroy(_ context.Context, info ports.WorkspaceInfo) error {
w.log.add("Workspace.Destroy")
if w.refuse[info.Path] {
// Worktree-remove safety: after `git worktree prune` the path is still
// registered, so it may hold the agent's uncommitted work — refuse.
return fmt.Errorf("workspace: refusing to rm -rf %s: still registered after prune", info.Path)
}
w.destroyed = append(w.destroyed, info)
return nil
}
func (w *fakeWorkspace) List(_ context.Context, _ domain.ProjectID) ([]ports.WorkspaceInfo, error) {
return nil, nil
}
func (w *fakeWorkspace) Restore(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
w.log.add("Workspace.Restore")
w.restoredID = append(w.restoredID, cfg.SessionID)
return workspaceFor(cfg), nil
}
func workspaceFor(cfg ports.WorkspaceConfig) ports.WorkspaceInfo {
return ports.WorkspaceInfo{
Path: "/tmp/ws/" + string(cfg.SessionID),
Branch: cfg.Branch,
SessionID: cfg.SessionID,
ProjectID: cfg.ProjectID,
}
}
// ---- recordingMessenger ----
type recordingMessenger struct {
sent []struct {
ID domain.SessionID
Message string
}
}
var _ ports.AgentMessenger = (*recordingMessenger)(nil)
func (m *recordingMessenger) Send(_ context.Context, id domain.SessionID, message string) error {
m.sent = append(m.sent, struct {
ID domain.SessionID
Message string
}{id, message})
return nil
}
// ---- noopNotifier ----
type noopNotifier struct{}
var _ ports.Notifier = (*noopNotifier)(nil)
func (noopNotifier) Notify(_ context.Context, _ ports.OrchestratorEvent) error { return nil }
// ---- recordingLCM: wraps the REAL lifecycle.Manager and logs SM-facing calls ----
type recordingLCM struct {
log *callLog
inner ports.LifecycleManager
// onSpawnErr, when set, makes OnSpawnCompleted fail (without touching the
// inner manager) so tests can exercise the SM's post-spawn failure paths.
onSpawnErr error
}
var _ ports.LifecycleManager = (*recordingLCM)(nil)
func (l *recordingLCM) OnSpawnCompleted(ctx context.Context, id domain.SessionID, o ports.SpawnOutcome) error {
l.log.add("OnSpawnCompleted")
if l.onSpawnErr != nil {
return l.onSpawnErr
}
return l.inner.OnSpawnCompleted(ctx, id, o)
}
func (l *recordingLCM) OnKillRequested(ctx context.Context, id domain.SessionID, r ports.KillReason) error {
l.log.add("OnKillRequested")
return l.inner.OnKillRequested(ctx, id, r)
}
func (l *recordingLCM) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error {
return l.inner.ApplySCMObservation(ctx, id, f)
}
func (l *recordingLCM) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error {
return l.inner.ApplyRuntimeObservation(ctx, id, f)
}
func (l *recordingLCM) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error {
return l.inner.ApplyActivitySignal(ctx, id, s)
}
func (l *recordingLCM) TickEscalations(ctx context.Context, now time.Time) error {
return l.inner.TickEscalations(ctx, now)
}
// ---- harness: wires the SM against the fakes + the real LCM ----
type harness struct {
sm *Manager
store *fakeStore
runtime *fakeRuntime
agent *fakeAgent
workspace *fakeWorkspace
messenger *recordingMessenger
lcm *recordingLCM
log *callLog
}
var fixedTime = time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)
func newHarness(id domain.SessionID) *harness {
log := &callLog{}
store := newFakeStore()
rt := &fakeRuntime{log: log, alive: true}
ag := &fakeAgent{env: map[string]string{"BASE": "1"}}
ws := &fakeWorkspace{log: log, refuse: map[string]bool{}}
msg := &recordingMessenger{}
lcm := &recordingLCM{log: log, inner: lifecycle.New(store, noopNotifier{}, msg)}
sm := New(Deps{
Runtime: rt,
Agent: ag,
Workspace: ws,
Store: store,
Messenger: msg,
Lifecycle: lcm,
Clock: func() time.Time { return fixedTime },
NewID: func(ports.SpawnConfig) domain.SessionID { return id },
})
return &harness{sm: sm, store: store, runtime: rt, agent: ag, workspace: ws, messenger: msg, lcm: lcm, log: log}
}
func cloneMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}