package daemon import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" telemetryadapter "github.com/aoagents/agent-orchestrator/backend/internal/adapters/telemetry" "github.com/aoagents/agent-orchestrator/backend/internal/cdc" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) // TestWiring_WriteFlowsToBroadcaster exercises the real boot path end to end: // a lifecycle write -> sqlite -> DB trigger -> change_log -> CDC poller -> // broadcaster, through the same cdc.Source implementation the daemon uses. func TestWiring_WriteFlowsToBroadcaster(t *testing.T) { ctx := context.Background() store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } defer store.Close() lcm := lifecycle.New(store, nil) bcast := cdc.NewBroadcaster() poller := cdc.NewPoller(store, bcast, cdc.PollerConfig{}) if err := poller.SeekToHead(ctx); err != nil { t.Fatal(err) } var mu sync.Mutex var got []cdc.Event bcast.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer"}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "mer", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, }) if err != nil { t.Fatal(err) } // A real transition through the engine, which writes the row and fires the // activity_state/is_terminated CDC trigger. if err := lcm.ApplyActivitySignal(ctx, rec.ID, ports.ActivitySignal{Valid: true, State: domain.ActivityActive, Timestamp: time.Now()}); err != nil { t.Fatal(err) } if err := poller.Poll(ctx); err != nil { t.Fatal(err) } mu.Lock() defer mu.Unlock() var sawSession bool for _, e := range got { if e.SessionID == string(rec.ID) { sawSession = true } } if !sawSession { t.Fatalf("expected a change_log event for %s to reach the broadcaster, got %d events", rec.ID, len(got)) } } // TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a // real registry-backed per-session resolver: each harness resolves to the // matching registered adapter, while empty and unknown harnesses miss. func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) resolver, err := buildAgentResolver("", log) // empty default → claude-code if err != nil { t.Fatal(err) } for _, tc := range []struct { harness domain.AgentHarness wantID string }{ {domain.HarnessClaudeCode, "claude-code"}, {domain.HarnessCodex, "codex"}, {domain.HarnessOpenCode, "opencode"}, {domain.HarnessGrok, "grok"}, {domain.HarnessCursor, "cursor"}, {domain.HarnessQwen, "qwen"}, {domain.HarnessCopilot, "copilot"}, {domain.HarnessKimi, "kimi"}, {domain.HarnessDroid, "droid"}, {domain.HarnessAmp, "amp"}, {domain.HarnessAgy, "agy"}, {domain.HarnessCrush, "crush"}, {domain.HarnessAider, "aider"}, {domain.HarnessGoose, "goose"}, {domain.HarnessAuggie, "auggie"}, {domain.HarnessContinue, "continue"}, {domain.HarnessDevin, "devin"}, {domain.HarnessCline, "cline"}, {domain.HarnessKiro, "kiro"}, {domain.HarnessKilocode, "kilocode"}, {domain.HarnessVibe, "vibe"}, {domain.HarnessPi, "pi"}, {domain.HarnessAutohand, "autohand"}, } { agent, ok := resolver.Agent(tc.harness) if !ok { t.Fatalf("resolver has no agent for harness %q", tc.harness) } described, ok := agent.(adapters.Adapter) if !ok { t.Fatalf("agent for harness %q is %T, not a registered adapters.Adapter", tc.harness, agent) } if got := described.Manifest().ID; got != tc.wantID { t.Fatalf("harness %q resolved to adapter %q, want %q", tc.harness, got, tc.wantID) } } if _, ok := resolver.Agent("definitely-not-an-agent"); ok { t.Fatal("unknown harness resolved to an agent; want a miss") } if _, ok := resolver.Agent(""); ok { t.Fatal("empty harness resolved to an agent; want a miss") } } // TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession // constructs a real controller-facing session service end to end (resolver + // gitworktree workspace + session manager over the shared store/LCM), which is // what gets mounted at httpd APIDeps.Sessions. func TestWiring_StartSessionBuildsSessionService(t *testing.T) { store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) lcm := lifecycle.New(store, nil) cfg := config.Config{DataDir: t.TempDir()} rt := runtimeselect.New(nil) messenger := newSessionMessenger(store, rt, log) svc, reviewSvc, lc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) if err != nil { t.Fatalf("startSession: %v", err) } if svc == nil { t.Fatal("startSession returned nil session service") } if reviewSvc == nil { t.Fatal("startSession returned nil review service") } if lc == nil { t.Fatal("startSession returned nil session lifecycle") } } type captureRuntimeSender struct { handle ports.RuntimeHandle message string } func (c *captureRuntimeSender) SendMessage(_ context.Context, handle ports.RuntimeHandle, message string) error { c.handle = handle c.message = message return nil } // TestWiring_SessionMessengerSendsToRuntimePane asserts the daemon wires ao // send to the live runtime pane and resolves the handle from the shared store. func TestWiring_SessionMessengerSendsToRuntimePane(t *testing.T) { store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) runtime := &captureRuntimeSender{} messenger := newSessionMessenger(store, runtime, nil) ctx := context.Background() if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "p", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, Metadata: domain.SessionMetadata{RuntimeHandleID: "ao-1/terminal_0"}, }) if err != nil { t.Fatal(err) } if err := messenger.Send(ctx, rec.ID, "hello agent"); err != nil { t.Fatalf("messenger.Send: %v", err) } if runtime.handle.ID != "ao-1/terminal_0" { t.Fatalf("handle = %q, want ao-1/terminal_0", runtime.handle.ID) } if runtime.message != "hello agent" { t.Fatalf("message = %q, want hello agent", runtime.message) } } func TestWiring_SessionMessengerWrapsLookupErrors(t *testing.T) { store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) messenger := newSessionMessenger(store, &captureRuntimeSender{}, nil) err = messenger.Send(context.Background(), "missing", "hello") if !errors.Is(err, sessionmanager.ErrNotFound) { t.Fatalf("missing session should wrap ErrNotFound, got %v", err) } } func TestWiring_SessionMessengerRequiresRuntimeHandle(t *testing.T) { store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) ctx := context.Background() if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "p", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, }) if err != nil { t.Fatal(err) } messenger := newSessionMessenger(store, &captureRuntimeSender{}, nil) err = messenger.Send(ctx, rec.ID, "hello") if !errors.Is(err, sessionmanager.ErrIncompleteHandle) { t.Fatalf("missing runtime handle should wrap ErrIncompleteHandle, got %v", err) } } func TestWiring_SessionMessengerRejectsTerminatedSession(t *testing.T) { store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) ctx := context.Background() if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "p", Kind: domain.KindWorker, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, Metadata: domain.SessionMetadata{RuntimeHandleID: "ao-1/terminal_0"}, }) if err != nil { t.Fatal(err) } runtime := &captureRuntimeSender{} messenger := newSessionMessenger(store, runtime, nil) err = messenger.Send(ctx, rec.ID, "hello") if !errors.Is(err, sessionmanager.ErrTerminated) { t.Fatalf("terminated session should wrap ErrTerminated, got %v", err) } if runtime.handle.ID != "" || runtime.message != "" { t.Fatalf("runtime should not be called for terminated sessions, got handle=%q message=%q", runtime.handle.ID, runtime.message) } } type captureMessenger struct { msgs []capturedMessage } type capturedMessage struct { id domain.SessionID msg string } func (c *captureMessenger) Send(_ context.Context, id domain.SessionID, msg string) error { c.msgs = append(c.msgs, capturedMessage{id: id, msg: msg}) return nil } // TestWiring_StartLifecycleThreadsMessengerIntoLCM asserts startLifecycle // constructs the LCM with a real messenger by driving an SCM observation // through the wired stack and checking the messenger receives the CI-failure // nudge — a nil messenger here would silently drop the send inside sendOnce. func TestWiring_StartLifecycleThreadsMessengerIntoLCM(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) // Cancel must run BEFORE Stop so the reaper goroutine's ctx.Done() fires; // Stop is a no-op otherwise. Cleanup is LIFO, so register Stop first. store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "p", Path: "/repo/p", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } rec, err := store.CreateSession(ctx, domain.SessionRecord{ ProjectID: "p", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}, }) if err != nil { t.Fatal(err) } log := slog.New(slog.NewTextHandler(io.Discard, nil)) messenger := &captureMessenger{} stack := startLifecycle(ctx, store, tmux.New(tmux.Options{}), messenger, nil, nil, log) t.Cleanup(stack.Stop) t.Cleanup(cancel) obs := ports.SCMObservation{ Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: "c1"}, CI: ports.SCMCIObservation{ Summary: string(domain.CIFailing), HeadSHA: "c1", FailedChecks: []ports.SCMCheckObservation{{Name: "build", Status: string(domain.PRCheckFailed), LogTail: "boom"}}, }, } if err := stack.LCM.ApplySCMObservation(ctx, rec.ID, obs); err != nil { t.Fatalf("ApplySCMObservation: %v", err) } if len(messenger.msgs) != 1 { t.Fatalf("want one nudge to flow through the wired messenger, got %d", len(messenger.msgs)) } if messenger.msgs[0].id != rec.ID { t.Fatalf("nudge sent to %q, want %q", messenger.msgs[0].id, rec.ID) } } // TestProjectRepoResolver_ResolvesRegisteredProject asserts the DB-backed repo // resolver turns a registered project into its on-disk repo path (so spawns // materialise a worktree), and fails loudly for an unregistered project. func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) { store, err := sqlite.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) ctx := context.Background() if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil { t.Fatal(err) } r := projectRepoResolver{store: store} got, err := r.RepoPath("mer") if err != nil { t.Fatalf("RepoPath(mer): %v", err) } if got != "/repo/mer" { t.Fatalf("RepoPath(mer) = %q, want /repo/mer", got) } _, err = r.RepoPath("nope") if err == nil { t.Fatal("expected an error for an unregistered project") } // Guard the sentinel wrapping so the HTTP 400 mapping can't silently regress. if !errors.Is(err, sessionmanager.ErrProjectNotResolvable) { t.Fatalf("unregistered-project error should wrap ErrProjectNotResolvable, got %v", err) } } // fakeSessionLifecycle records calls to Reconcile, RestoreAll, and // SaveAndTeardownAll so tests can assert the daemon wiring invokes the correct // methods without needing a real runtime or worktree. type fakeSessionLifecycle struct { reconcileCalled bool restoreAllCalled bool saveAndTeardownCalled bool reconcileErr error restoreErr error saveErr error } func (f *fakeSessionLifecycle) Reconcile(_ context.Context) error { f.reconcileCalled = true return f.reconcileErr } func (f *fakeSessionLifecycle) RestoreAll(_ context.Context) error { f.restoreAllCalled = true return f.restoreErr } func (f *fakeSessionLifecycle) SaveAndTeardownAll(_ context.Context) error { f.saveAndTeardownCalled = true return f.saveErr } // TestWiring_SessionLifecycleInterfaceInvokedByDaemon asserts the // sessionLifecycle interface is satisfied by *sessionmanager.Manager (compile // check) and that Reconcile, RestoreAll, and SaveAndTeardownAll dispatch // correctly through the interface, matching what daemon.go wires at // boot/shutdown. func TestWiring_SessionLifecycleInterfaceInvokedByDaemon(t *testing.T) { // Verify *sessionmanager.Manager satisfies the interface at compile time. var _ sessionLifecycle = (*sessionmanager.Manager)(nil) fake := &fakeSessionLifecycle{} ctx := context.Background() // Dispatch through the interface variable to exercise the real dispatch // path, not just direct struct method calls. var sl sessionLifecycle = fake if err := sl.Reconcile(ctx); err != nil { t.Fatalf("Reconcile: %v", err) } if !fake.reconcileCalled { t.Fatal("Reconcile was not called through the interface") } if err := sl.RestoreAll(ctx); err != nil { t.Fatalf("RestoreAll: %v", err) } if !fake.restoreAllCalled { t.Fatal("RestoreAll was not called through the interface") } if err := sl.SaveAndTeardownAll(ctx); err != nil { t.Fatalf("SaveAndTeardownAll: %v", err) } if !fake.saveAndTeardownCalled { t.Fatal("SaveAndTeardownAll was not called through the interface") } }