package tui import ( "strings" "testing" "github.com/smallnest/pigo/internal/agentcore" ) // newRemoteTestSession builds a fresh run session over a temp-dir store for the // remote-control lifecycle tests (no resume, no tools). func newRemoteTestSession(t *testing.T) *runSession { t.Helper() store := newTestStore(t) s, _, err := newRunSessionWithStore(store, Options{}) if err != nil { t.Fatalf("newRunSessionWithStore: %v", err) } return s } // TestStartStopRemote covers the start→already-running→stop lifecycle on // runSession: startRemote binds a listener and stores the session, a second // start reports "already running" without replacing it, and stopRemote clears // the session so a later start can rebind. func TestStartStopRemote(t *testing.T) { s := newRemoteTestSession(t) if s.remote != nil { t.Fatal("remote should be nil before start") } url, err := s.startRemote() if err != nil { t.Fatalf("startRemote: %v", err) } if url == "" { t.Fatal("startRemote returned empty url") } if s.remote == nil { t.Fatal("remote should be set after start") } first := s.remote // A second start is a no-op that reports the existing url and an error, // leaving the running session untouched. url2, err := s.startRemote() if err == nil { t.Error("second startRemote should report already-running error") } if url2 != url { t.Errorf("second startRemote url = %q, want %q", url2, url) } if s.remote != first { t.Error("second startRemote must not replace the running session") } s.stopRemote() if s.remote != nil { t.Error("remote should be nil after stop") } // Stop again is a no-op. s.stopRemote() // After stopping, a fresh start rebinds cleanly. if _, err := s.startRemote(); err != nil { t.Fatalf("restart after stop: %v", err) } s.stopRemote() } // TestBuildConfigInstallsRemoteSeam asserts buildConfig only installs the // BeforeToolCall confirm seam while a remote session is present: off by default // (up-front trust unchanged), wired once /remote-control is running. func TestBuildConfigInstallsRemoteSeam(t *testing.T) { s := newRemoteTestSession(t) if cfg := s.buildConfig(); cfg.Batch.ToolExecutorConfig.BeforeToolCall != nil { t.Error("BeforeToolCall should be nil when remote control is off") } if _, err := s.startRemote(); err != nil { t.Fatalf("startRemote: %v", err) } defer s.stopRemote() if cfg := s.buildConfig(); cfg.Batch.ToolExecutorConfig.BeforeToolCall == nil { t.Error("BeforeToolCall should be installed when remote control is on") } } // TestRemoteConfirmSeamAllowsWhenNoClient verifies the confirm seam is a no-op // (returns nil = allow under up-front trust) when no browser is connected, so a // running-but-unpaired server never blocks tool calls. func TestRemoteConfirmSeamAllowsWhenNoClient(t *testing.T) { s := newRemoteTestSession(t) if _, err := s.startRemote(); err != nil { t.Fatalf("startRemote: %v", err) } defer s.stopRemote() // No client is paired, so hasClient() is false and the seam must allow. seam := remoteConfirmSeam(s.remote, nil, "/tmp/project") if d := seam(t.Context(), agentcore.AgentToolCall{Name: "bash"}); d != nil { t.Errorf("seam should allow (nil) with no client, got %+v", d) } } // TestRemoteInputIgnoredWhileRunning routes a remoteInputMsg into an idle vs a // running Model: while a run is in flight the prompt is not started (a busy note // is shown instead), and the listener is always re-issued so later submissions // keep arriving. func TestRemoteInputIgnoredWhileRunning(t *testing.T) { s := newRemoteTestSession(t) if _, err := s.startRemote(); err != nil { t.Fatalf("startRemote: %v", err) } defer s.stopRemote() m := NewModel(Options{}) m.session = s m.running = true updated, _ := m.Update(remoteInputMsg{text: "do something"}) got := updated.(Model) if !hasSystemBlockContaining(got.transcript, "a run is in progress") { t.Errorf("expected a busy note in the transcript while running, blocks=%v", blockTexts(got.transcript)) } // A run in progress must not consume the remote prompt as a new turn. if !got.running { t.Error("model should still be running; the remote prompt must not start a turn") } } // hasSystemBlockContaining reports whether any transcript block's text contains // sub (case-insensitive substring over the rendered block texts). func hasSystemBlockContaining(t transcript, sub string) bool { for _, s := range blockTexts(t) { if strings.Contains(strings.ToLower(s), strings.ToLower(sub)) { return true } } return false }